/* * 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);