- Add shared BlogMD renderer (static/js/markdown.js): marked + DOMPurify + highlight.js pipeline with GFM support, heading id slugger with CJK-aware anchors, syntax highlighting, per-block copy button and language badge, lazy images with lightbox, external links opened safely in new tabs, tables wrapped for small screens. - Add .md-body typography styles (static/css/markdown.css) so articles, comments and editor previews render with proper headings, tables, lists, blockquotes and code blocks (previously the prose classes had no effect because the Tailwind typography plugin is not loaded). - Fix marked options that were set after parsing and removed from marked v4+ (mangle/headerIds no-ops). - Pin CDN versions (marked 15.0.12, dompurify 3.4.13, highlight.js 11.12.0) instead of floating 'latest' URLs. - Wire EasyMDE preview/side-by-side to BlogMD in admin and user article editors; use BlogMD for comment bodies on the article page, admin comment list and comment preview. - Serve /static in main.go and deploy it in install_linux.sh.
323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
/*
|
|
* markdown.js — enhanced Markdown rendering for Go Blog.
|
|
*
|
|
* Wraps marked + DOMPurify + highlight.js into one global `BlogMD` object:
|
|
*
|
|
* BlogMD.render(md) -> sanitized & enhanced HTML string
|
|
* BlogMD.renderInto(el, md) -> renders into an element in place
|
|
* BlogMD.init() -> (optional) binds delegated interactions
|
|
*
|
|
* Enhancements applied after parsing:
|
|
* - GFM (tables, task lists, strikethrough, autolinks)
|
|
* - heading permalink anchors (ids are generated by marked)
|
|
* - external links open in a new tab with rel="noopener noreferrer"
|
|
* - images are lazy-loaded and open in a lightbox on click
|
|
* - code blocks get syntax highlighting + a copy button + language badge
|
|
* - tables are wrapped for horizontal scrolling on small screens
|
|
*
|
|
* All output passes through DOMPurify, so raw HTML inside Markdown is
|
|
* sanitized before it touches the DOM.
|
|
*/
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
function missing() {
|
|
return typeof marked === 'undefined' || typeof DOMPurify === 'undefined';
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// heading id slugger (marked no longer generates ids by default)
|
|
// ------------------------------------------------------------------
|
|
// GitHub-style, but keeps CJK letters so Chinese headings get
|
|
// meaningful anchors. One instance per document render.
|
|
function makeSlugger() {
|
|
var counts = {};
|
|
return function (text) {
|
|
var slug = text
|
|
.replace(/<[^>]+>/g, '') // drop inline HTML (code, em, …)
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\p{L}\p{N}\u3000-\u303f]+/gu, '-') // runs -> single '-'
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
if (!slug) slug = 'section';
|
|
if (counts[slug] === undefined) counts[slug] = 0;
|
|
counts[slug] += 1;
|
|
return counts[slug] === 1 ? slug : slug + '-' + (counts[slug] - 1);
|
|
};
|
|
}
|
|
|
|
var slugger = makeSlugger();
|
|
|
|
// ------------------------------------------------------------------
|
|
// marked configuration (once)
|
|
// ------------------------------------------------------------------
|
|
if (!missing() && !window.__blogMdConfigured) {
|
|
window.__blogMdConfigured = true;
|
|
marked.use({
|
|
gfm: true, // tables, task lists, strikethrough, autolinks
|
|
breaks: false, // single newline does NOT become <br> (standard Markdown)
|
|
renderer: {
|
|
// re-enable heading ids (removed from marked core in v4)
|
|
heading: function (token) {
|
|
var text = this.parser.parseInline(token.tokens);
|
|
var id = slugger(text);
|
|
return '<h' + token.depth + ' id="' + id + '">' + text + '</h' + token.depth + '>';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
var PURIFY_CONFIG = {
|
|
// `loading` is not in DOMPurify's default allow-list; everything
|
|
// else we emit (id, class, target, rel, checked, disabled, type)
|
|
// is allowed by default.
|
|
ADD_ATTR: ['loading']
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// DOM post-processing
|
|
// ------------------------------------------------------------------
|
|
|
|
function processRoot(root) {
|
|
if (!root || !root.querySelectorAll) return;
|
|
|
|
// 1) heading permalinks (marked already assigns ids)
|
|
root.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]').forEach(function (h) {
|
|
if (h.querySelector('.md-anchor')) return;
|
|
var a = document.createElement('a');
|
|
a.className = 'md-anchor';
|
|
a.href = '#' + h.id;
|
|
a.setAttribute('aria-hidden', 'true');
|
|
a.setAttribute('title', h.textContent);
|
|
a.textContent = '#';
|
|
h.appendChild(a);
|
|
});
|
|
|
|
// 2) external links -> new tab, safe rel
|
|
root.querySelectorAll('a[href]').forEach(function (a) {
|
|
var href = a.getAttribute('href') || '';
|
|
if (/^https?:\/\//i.test(href) || href.indexOf('//') === 0) {
|
|
a.setAttribute('target', '_blank');
|
|
a.setAttribute('rel', 'noopener noreferrer');
|
|
}
|
|
});
|
|
|
|
// 3) images: lazy load + zoom on click (lightbox is delegated)
|
|
root.querySelectorAll('img').forEach(function (img) {
|
|
img.setAttribute('loading', 'lazy');
|
|
img.classList.add('md-img');
|
|
if (!img.getAttribute('alt')) img.setAttribute('alt', '');
|
|
});
|
|
|
|
// 4) tables: wrap for horizontal scroll
|
|
root.querySelectorAll('table').forEach(function (table) {
|
|
var parent = table.parentNode;
|
|
if (parent && parent.classList && parent.classList.contains('md-table-wrap')) return;
|
|
var wrap = document.createElement('div');
|
|
wrap.className = 'md-table-wrap';
|
|
table.parentNode.insertBefore(wrap, table);
|
|
wrap.appendChild(table);
|
|
});
|
|
|
|
// 5) code blocks: highlight + copy button + language badge
|
|
root.querySelectorAll('pre code').forEach(function (code) {
|
|
var pre = code.parentNode;
|
|
if (pre && pre.querySelector && pre.querySelector('.md-copy-btn')) return;
|
|
|
|
// syntax highlighting (only when a language is declared)
|
|
if (typeof hljs !== 'undefined') {
|
|
var langMatch = /(?:^|\s)language-([\w-]+)/.exec(code.className || '');
|
|
if (langMatch) {
|
|
try { hljs.highlightElement(code); } catch (e) { /* keep plain */ }
|
|
}
|
|
}
|
|
|
|
// language badge
|
|
var lang = /(?:^|\s)language-([\w-]+)/.exec(code.className || '');
|
|
if (lang) {
|
|
var badge = document.createElement('span');
|
|
badge.className = 'md-lang';
|
|
badge.textContent = lang[1].length > 14 ? lang[1].slice(0, 14) + '…' : lang[1];
|
|
pre.appendChild(badge);
|
|
}
|
|
|
|
// copy button (delegated click handler, see initInteractions)
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'md-copy-btn';
|
|
btn.title = 'Copy code';
|
|
btn.setAttribute('aria-label', 'Copy code');
|
|
btn.innerHTML =
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>' +
|
|
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
pre.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// clipboard helpers
|
|
// ------------------------------------------------------------------
|
|
|
|
function copyText(text, btn) {
|
|
function done() {
|
|
btn.classList.add('md-copied');
|
|
btn.textContent = '✓';
|
|
setTimeout(function () {
|
|
btn.classList.remove('md-copied');
|
|
btn.innerHTML =
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>' +
|
|
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
}, 1500);
|
|
}
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(done).catch(function () {
|
|
legacyCopy(text, done);
|
|
});
|
|
} else {
|
|
legacyCopy(text, done);
|
|
}
|
|
}
|
|
|
|
function legacyCopy(text, done) {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.setAttribute('readonly', '');
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
done();
|
|
} catch (e) { /* clipboard unavailable */ }
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// lightbox
|
|
// ------------------------------------------------------------------
|
|
|
|
function ensureLightbox() {
|
|
var lb = document.getElementById('mdLightbox');
|
|
if (lb) return lb;
|
|
lb = document.createElement('div');
|
|
lb.id = 'mdLightbox';
|
|
lb.setAttribute('role', 'dialog');
|
|
lb.setAttribute('aria-modal', 'true');
|
|
lb.innerHTML =
|
|
'<button type="button" class="md-lightbox-close" aria-label="Close">×</button>' +
|
|
'<img alt="">' +
|
|
'<div class="md-lightbox-caption"></div>';
|
|
document.body.appendChild(lb);
|
|
|
|
lb.addEventListener('click', function (e) {
|
|
if (e.target === lb || e.target.classList.contains('md-lightbox-close')) {
|
|
closeLightbox();
|
|
}
|
|
});
|
|
return lb;
|
|
}
|
|
|
|
function openLightbox(img) {
|
|
var lb = ensureLightbox();
|
|
lb.querySelector('img').src = img.currentSrc || img.src;
|
|
var cap = lb.querySelector('.md-lightbox-caption');
|
|
var alt = (img.getAttribute('alt') || '').trim();
|
|
cap.textContent = alt;
|
|
cap.style.display = alt ? '' : 'none';
|
|
lb.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeLightbox() {
|
|
var lb = document.getElementById('mdLightbox');
|
|
if (!lb) return;
|
|
lb.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// delegated interactions
|
|
// ------------------------------------------------------------------
|
|
// Copy buttons are rendered into HTML strings that get re-parsed via
|
|
// innerHTML, so listeners must live on the document, not the buttons.
|
|
|
|
var interactionsReady = false;
|
|
|
|
function initInteractions() {
|
|
if (interactionsReady) return;
|
|
interactionsReady = true;
|
|
|
|
// code copy (delegated)
|
|
document.addEventListener('click', function (e) {
|
|
var btn = e.target && e.target.closest ? e.target.closest('.md-copy-btn') : null;
|
|
if (!btn) return;
|
|
var pre = btn.closest('pre');
|
|
var code = pre && pre.querySelector('code');
|
|
if (!code) return;
|
|
copyText(code.textContent || '', btn);
|
|
});
|
|
|
|
// image lightbox (delegated, works for content rendered at any time)
|
|
document.addEventListener('click', function (e) {
|
|
var img = e.target;
|
|
if (!img || !img.closest) return;
|
|
var target = img.closest('.md-body img');
|
|
if (!target) return;
|
|
// images wrapped in a link keep normal navigation
|
|
if (target.closest('a')) return;
|
|
e.preventDefault();
|
|
openLightbox(target);
|
|
});
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') closeLightbox();
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// public API
|
|
// ------------------------------------------------------------------
|
|
|
|
function render(md) {
|
|
if (missing()) {
|
|
// dependency unavailable: show the raw text, HTML-escaped
|
|
var esc = document.createElement('div');
|
|
esc.textContent = String(md == null ? '' : md);
|
|
return esc.innerHTML;
|
|
}
|
|
var html;
|
|
try {
|
|
slugger = makeSlugger(); // fresh id namespace per document
|
|
html = marked.parse(String(md == null ? '' : md));
|
|
} catch (e) {
|
|
html = String(md == null ? '' : md);
|
|
}
|
|
var clean = DOMPurify.sanitize(html, PURIFY_CONFIG);
|
|
var div = document.createElement('div');
|
|
div.innerHTML = clean;
|
|
processRoot(div);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function renderInto(el, md) {
|
|
if (!el) return el;
|
|
el.innerHTML = render(md);
|
|
return el;
|
|
}
|
|
|
|
global.BlogMD = {
|
|
render: render,
|
|
renderInto: renderInto,
|
|
init: initInteractions
|
|
};
|
|
|
|
initInteractions(); // safe to bind immediately (delegated, lazily built DOM)
|
|
})(window);
|