Refactoring MarkDown behavior.

This commit is contained in:
Yaro Kasear 2025-10-21 11:33:11 -05:00
parent 6357e5794f
commit 38bae34247
4 changed files with 108 additions and 122 deletions

View file

@ -0,0 +1,30 @@
const MarkDown = {
parseOptions: { gfm: true, breaks: false },
sanitizeOptions: { ADD_ATTR: ['target', 'rel'] },
toHTML(md) {
const raw = marked.parse(md || "", this.parseOptions);
return DOMPurify.sanitize(raw, this.sanitizeOptions);
},
enhance(root) {
if (!root) return;
for (const a of root.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
for (const t of root.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
for (const q of root.querySelectorAll('blockquote')) {
q.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
}
},
renderInto(el, md) {
if (!el) return;
el.innerHTML = this.toHTML(md);
this.enhance(el);
}
};

View file

@ -0,0 +1,7 @@
function readJSONScript(id, fallback = "") {
const el = document.getElementById(id);
if (!el) return fallback;
const txt = el.textContent?.trim();
if (!txt) return fallback;
try { return JSON.parse(txt); } catch { return fallback; }
}

View file

@ -28,89 +28,76 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
renderView(getMarkdown()); MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
}); });
function getMarkdown() { // used by entry_buttons submit
const el = document.getElementById('noteContent'); window.getMarkdown = function () {
return el ? (JSON.parse(el.textContent) || "") : ""; return readJSONScript('noteContent', "");
};
function setMarkdown(md) {
const el = document.getElementById('noteContent');
if (el) el.textContent = JSON.stringify(md ?? "");
}
function changeMode() {
const container = document.getElementById('editContainer');
const toggle = document.getElementById('editSwitch');
if (!toggle.checked) {
MarkDown.renderInto(container, getMarkdown());
return;
} }
function setMarkdown(md) { const current = getMarkdown();
const el = document.getElementById('noteContent'); container.innerHTML = `
if (el) el.textContent = JSON.stringify(md ?? ""); <textarea class="form-control w-100 auto-md" id="editor" name="notes">${escapeForTextarea(current)}</textarea>
} <div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="saveEdit()">Save</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit()">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="togglePreview()">Preview</button>
</div>
<div class="mt-2 border rounded p-2 d-none" id="preview" aria-live="polite"></div>
`;
const ta = document.getElementById('editor');
autoGrow(ta);
ta.addEventListener('input', () => autoGrow(ta));
}
function renderView(md) { function saveEdit() {
const container = document.getElementById('editContainer'); const ta = document.getElementById('editor');
if (!container) return; const value = ta ? ta.value : "";
const html = marked.parse(md || "", {gfm: true}); setMarkdown(value);
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] }); MarkDown.renderInto(document.getElementById('editContainer'), value);
for (const a of container.querySelectorAll('a[href]')) { document.getElementById('editSwitch').checked = false;
a.setAttribute('target', '_blank'); }
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
for (const t of container.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
for (const t of container.querySelectorAll('blockquote')) {
t.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
}
}
function changeMode() { function cancelEdit() {
const container = document.getElementById('editContainer'); document.getElementById('editSwitch').checked = false;
const toggle = document.getElementById('editSwitch'); MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
if (!toggle.checked) return renderView(getMarkdown()); }
const current = getMarkdown(); function togglePreview() {
container.innerHTML = ` const ta = document.getElementById('editor');
<textarea class="form-control w-100 auto-md" id="editor" name="notes">${escapeForTextarea(current)}</textarea> const preview = document.getElementById('preview');
<div class="mt-2 d-flex gap-2"> preview.classList.toggle('d-none');
<button type="button" class="btn btn-primary btn-sm" onclick="saveEdit()">Save</button> if (!preview.classList.contains('d-none')) {
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit()">Cancel</button> MarkDown.renderInto(preview, ta ? ta.value : "");
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="togglePreview()">Preview</button>
</div>
<div class="mt-2 border rounded p-2 d-none" id="preview" aria-live="polite"></div>
`;
const ta = document.getElementById('editor');
autoGrow(ta);
ta.addEventListener('input', () => autoGrow(ta));
} }
}
function saveEdit() { function autoGrow(ta) {
const ta = document.getElementById('editor'); if (!ta) return;
const value = ta ? ta.value : ""; if (CSS?.supports?.('field-sizing: content')) return;
setMarkdown(value); ta.style.height = 'auto';
renderView(value); ta.style.height = ta.scrollHeight + 'px';
document.getElementById('editSwitch').checked = false; }
}
function cancelEdit() { function escapeForTextarea(s) {
document.getElementById('editSwitch').checked = false; return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
renderView(getMarkdown()); }
} </script>
function togglePreview() {
const ta = document.getElementById('editor');
const preview = document.getElementById('preview');
preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) {
const html = marked.parse(ta ? ta.value : "");
preview.innerHTML = DOMPurify.sanitize(html);
}
}
function autoGrow(ta) {
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
function escapeForTextarea(s) {
return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>

View file

@ -16,7 +16,7 @@
<ul class="list-group mt-3"> <ul class="list-group mt-3">
{% for n in items %} {% for n in items %}
<li class="list-group-item"> <li class="list-group-item" id="note-{{ n.id }}">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div> <div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div>
<script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script> <script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script>
@ -63,52 +63,15 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script> <script>
// State (kept global for compatibility with your form serialization) // State (kept global for compatibility with your form serialization)
window.newDrafts = window.newDrafts || []; window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || []; window.deletedIds = window.deletedIds || [];
// ---------- DRY UTILITIES ----------
function renderMarkdown(md) {
// One place to parse + sanitize
const raw = marked.parse(md || "");
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
}
function enhanceLinks(root) {
if (!root) return;
for (const a of root.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
}
function enhanceTables(root) {
if (!root) return;
for (const t of root.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
}
function enhanceBlockquotes(root) {
if (!root) return;
for (const t of root.querySelectorAll('blockquote')) {
t.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
}
}
function renderHTML(el, md) {
if (!el) return;
el.innerHTML = renderMarkdown(md);
enhanceLinks(el);
enhanceTables(el);
enhanceBlockquotes(el);
}
function getMarkdown(id) { function getMarkdown(id) {
const el = document.getElementById(`md-${id}`); return readJSONScript(`md-${id}`, "");
return el ? JSON.parse(el.textContent || '""') : "";
} }
function setMarkdown(id, md) { function setMarkdown(id, md) {
@ -122,14 +85,14 @@
function autoGrow(ta) { function autoGrow(ta) {
if (!ta) return; if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto'; ta.style.height = 'auto';
ta.style.height = (ta.scrollHeight + 5) + 'px'; ta.style.height = (ta.scrollHeight + 5) + 'px';
} }
// ---------- RENDERERS ---------- // ---------- RENDERERS ----------
function renderExistingView(id) { function renderExistingView(id) {
const container = document.getElementById(`editContainer${id}`); MarkDown.renderInto(document.getElementById(`editContainer${id}`), getMarkdown(id));
renderHTML(container, getMarkdown(id));
} }
function renderEditor(id) { function renderEditor(id) {
@ -167,8 +130,7 @@
const left = document.createElement('div'); const left = document.createElement('div');
left.className = 'w-100 markdown-body'; left.className = 'w-100 markdown-body';
left.innerHTML = renderMarkdown(md || ''); MarkDown.renderInto(left, md || '');
enhanceLinks(left);
const right = document.createElement('div'); const right = document.createElement('div');
right.className = 'ms-3 d-flex flex-column align-items-end'; right.className = 'ms-3 d-flex flex-column align-items-end';
@ -271,7 +233,7 @@
if (!preview) return; if (!preview) return;
preview.classList.toggle('d-none'); preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) { if (!preview.classList.contains('d-none')) {
preview.innerHTML = renderMarkdown(ta ? ta.value : ""); MarkDown.renderInto(preview, ta ? ta.value : "");
} }
} }