/*
* 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 (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 '' + text + '';
}
}
});
}
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 =
'';
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 =
'';
}, 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 =
'' +
'' +
'
';
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);