From 83866f6de2ede6b741dae9337497362c1d396b29 Mon Sep 17 00:00:00 2001 From: dsh Date: Tue, 18 Aug 2026 06:05:53 -0400 Subject: [PATCH] feat: enhance frontend Markdown rendering - 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. --- README.md | 1 + install_linux.sh | 5 + main.go | 3 + static/css/markdown.css | 377 ++++++++++++++++++++++++++++ static/js/markdown.js | 322 ++++++++++++++++++++++++ templates/admin/article_create.html | 4 + templates/admin/comment_list.html | 9 +- templates/layouts/base.html | 12 + templates/pages/article.html | 31 +-- templates/pages/comment_node.html | 2 +- templates/user/my_article_form.html | 4 + 11 files changed, 744 insertions(+), 26 deletions(-) create mode 100644 static/css/markdown.css create mode 100644 static/js/markdown.js diff --git a/README.md b/README.md index bbf4158..79d7ada 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## 功能特性 - **文章管理** — 文章的创建、编辑、删除,支持标签分类、自定义发布时间和更新时间 +- **Markdown 渲染** — 文章与评论支持 GFM Markdown(表格、任务列表、删除线),代码块语法高亮 + 一键复制,标题锚点链接,图片懒加载与灯箱预览 - **评论系统** — 文章评论功能,后台可审核(通过/拒绝/删除),支持评论设置 - **用户系统** — 用户注册、登录,角色分为管理员(admin)和作者(author) - **角色权限** — 管理员可访问后台管理面板;作者可管理自己的文章 diff --git a/install_linux.sh b/install_linux.sh index dcfed17..cc2d4d1 100755 --- a/install_linux.sh +++ b/install_linux.sh @@ -40,10 +40,15 @@ install -m 0755 -o root -g root "${SCRIPT_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/$ rm -rf "${INSTALL_DIR}/templates" cp -a "${SCRIPT_DIR}/templates" "${INSTALL_DIR}/templates" chown -R root:root "${INSTALL_DIR}/templates" +rm -rf "${INSTALL_DIR}/static" +cp -a "${SCRIPT_DIR}/static" "${INSTALL_DIR}/static" +chown -R root:root "${INSTALL_DIR}/static" chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}" chmod 0755 "${INSTALL_DIR}" find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \; find "${INSTALL_DIR}/templates" -type f -exec chmod 0644 {} \; +find "${INSTALL_DIR}/static" -type d -exec chmod 0755 {} \; +find "${INSTALL_DIR}/static" -type f -exec chmod 0644 {} \; SOCKET_PATH="${SOCKET_DIR}/web.sock" diff --git a/main.go b/main.go index 650cd12..e76f56e 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,9 @@ func main() { // 6. Serve uploaded files (avatars etc.) from the storage path. router.Static("/uploads", cfg.Path) + // 6b. Serve bundled static assets (CSS/JS for Markdown rendering). + router.Static("/static", "./static") + // 6. Global session middleware. router.Use(sessions.Sessions("blog_session", store)) diff --git a/static/css/markdown.css b/static/css/markdown.css new file mode 100644 index 0000000..3250538 --- /dev/null +++ b/static/css/markdown.css @@ -0,0 +1,377 @@ +/* + * markdown.css — typography and components for rendered Markdown + * (article body, comments, editor previews). + * + * All rules are scoped under .md-body so they never leak into the rest + * of the page. Sizes are written in em so the content scales with the + * container's font size (articles are base size, comments are text-sm). + */ + +.md-body { + font-size: 1em; + line-height: 1.75; + color: #1f2937; /* gray-800 */ + word-wrap: break-word; + overflow-wrap: break-word; +} + +/* ---------- paragraphs ---------- */ +.md-body p { + margin: 0 0 1em; +} + +.md-body > :first-child { + margin-top: 0; +} +.md-body > :last-child { + margin-bottom: 0; +} + +/* ---------- headings ---------- */ +.md-body h1, +.md-body h2, +.md-body h3, +.md-body h4, +.md-body h5, +.md-body h6 { + font-weight: 700; + color: #111827; /* gray-900 */ + line-height: 1.3; + margin: 1.6em 0 0.6em; + scroll-margin-top: 1rem; +} + +.md-body h1 { + font-size: 1.875em; + padding-bottom: 0.3em; + border-bottom: 1px solid #e5e7eb; +} +.md-body h2 { + font-size: 1.5em; + padding-bottom: 0.3em; + border-bottom: 1px solid #e5e7eb; +} +.md-body h3 { font-size: 1.25em; } +.md-body h4 { font-size: 1.1em; } +.md-body h5 { font-size: 1em; } +.md-body h6 { + font-size: 0.9em; + color: #4b5563; /* gray-600 */ + text-transform: uppercase; + letter-spacing: 0.02em; +} + +/* heading permalink (#) shown on hover */ +.md-body .md-anchor { + display: inline-block; + margin-left: 0.4em; + font-size: 0.8em; + font-weight: 600; + color: #9ca3af; /* gray-400 */ + text-decoration: none; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s ease; +} +.md-body h1:hover .md-anchor, +.md-body h2:hover .md-anchor, +.md-body h3:hover .md-anchor, +.md-body h4:hover .md-anchor, +.md-body h5:hover .md-anchor, +.md-body h6:hover .md-anchor { + visibility: visible; + opacity: 1; +} +.md-body .md-anchor:hover { + color: #2563eb; /* blue-600 */ +} + +/* ---------- links ---------- */ +.md-body a { + color: #2563eb; /* blue-600 */ + text-decoration: none; + border-bottom: 1px solid rgba(37, 99, 235, 0.3); + transition: border-color 0.15s ease; +} +.md-body a:hover { + color: #1d4ed8; /* blue-700 */ + border-bottom-color: currentColor; +} + +/* ---------- emphasis ---------- */ +.md-body strong { font-weight: 700; color: #111827; } +.md-body em { font-style: italic; } +.md-body del { color: #6b7280; } +.md-body mark { + background: #fef08a; /* yellow-200 */ + color: #1f2937; + padding: 0 0.2em; + border-radius: 0.25em; +} +.md-body kbd { + display: inline-block; + padding: 0.15em 0.45em; + font-size: 0.85em; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #374151; + background: #f9fafb; + border: 1px solid #d1d5db; + border-bottom-width: 2px; + border-radius: 0.35em; +} + +/* ---------- inline code ---------- */ +.md-body code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.875em; + background: #f3f4f6; /* gray-100 */ + color: #111827; + padding: 0.2em 0.4em; + border-radius: 0.375em; +} +.md-body pre code { + background: transparent; + color: inherit; + padding: 0; + font-size: 0.9em; + border-radius: 0; + display: block; + overflow-x: auto; +} + +/* ---------- code blocks ---------- */ +.md-body pre { + position: relative; + margin: 1em 0; + padding: 1rem 1.1rem; + background: #f6f8fa; /* github light */ + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + overflow-x: auto; + line-height: 1.6; + tab-size: 4; +} + +/* language badge (top-left) */ +.md-body .md-lang { + position: absolute; + top: 0.5rem; + left: 0.9rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9ca3af; + user-select: none; + pointer-events: none; +} + +/* copy button (top-right, appears on hover) */ +.md-body .md-copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + color: #6b7280; + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease; + z-index: 1; +} +.md-body pre:hover .md-copy-btn { + opacity: 1; +} +.md-body .md-copy-btn:hover { + color: #1f2937; + border-color: #9ca3af; +} +.md-body .md-copy-btn.md-copied { + color: #059669; /* green-600 */ + border-color: #059669; +} +.md-body .md-copy-btn svg { + width: 0.85em; + height: 0.85em; +} +@media (hover: none) { + .md-body .md-copy-btn { opacity: 1; } +} + +/* ---------- lists ---------- */ +.md-body ul, +.md-body ol { + margin: 0 0 1em; + padding-left: 1.6em; +} +.md-body ul { list-style: disc; } +.md-body ol { list-style: decimal; } +.md-body li { + margin: 0.3em 0; +} +.md-body li > ul, +.md-body li > ol { + margin: 0.3em 0; +} +.md-body li > p { + margin: 0.3em 0; +} + +/* GFM task lists */ +.md-body li:has(> input[type="checkbox"]) { + list-style: none; + margin-left: -1.6em; +} +.md-body input[type="checkbox"] { + margin-right: 0.5em; + accent-color: #2563eb; + transform: translateY(0.1em); +} + +/* ---------- blockquote ---------- */ +.md-body blockquote { + margin: 1em 0; + padding: 0.6em 1em; + color: #4b5563; /* gray-600 */ + background: #f9fafb; /* gray-50 */ + border-left: 4px solid #d1d5db; + border-radius: 0 0.5rem 0.5rem 0; +} +.md-body blockquote > :last-child { + margin-bottom: 0; +} +.md-body blockquote > :first-child { + margin-top: 0; +} + +/* ---------- tables ---------- */ +.md-body .md-table-wrap { + margin: 1em 0; + overflow-x: auto; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} +.md-body table { + width: 100%; + border-collapse: collapse; + font-size: 0.95em; +} +.md-body th, +.md-body td { + padding: 0.5em 0.9em; + border: 1px solid #e5e7eb; + text-align: left; + vertical-align: top; +} +.md-body th { + font-weight: 600; + background: #f9fafb; + color: #111827; + white-space: nowrap; +} +.md-body tbody tr:nth-child(even) { + background: #f9fafb; +} +.md-body tbody tr:hover { + background: #f3f4f6; +} + +/* ---------- images ---------- */ +.md-body img { + max-width: 100%; + height: auto; + margin: 0.5em 0; + border-radius: 0.5rem; + cursor: zoom-in; +} + +/* ---------- horizontal rule ---------- */ +.md-body hr { + margin: 2em 0; + border: 0; + border-top: 1px solid #e5e7eb; +} + +/* ---------- details (rendered by some md flavors) ---------- */ +.md-body details { + margin: 1em 0; + padding: 0.6em 1em; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} +.md-body summary { + cursor: pointer; + font-weight: 600; + color: #111827; +} + +/* ---------- lightbox ---------- */ +#mdLightbox { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(17, 24, 39, 0.9); + padding: 2rem; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; +} +#mdLightbox.open { + opacity: 1; + visibility: visible; +} +#mdLightbox img { + max-width: 92vw; + max-height: 88vh; + border-radius: 0.5rem; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} +#mdLightbox .md-lightbox-close { + position: absolute; + top: 1rem; + right: 1.25rem; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + line-height: 1; + color: #e5e7eb; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 9999px; + cursor: pointer; + transition: background 0.15s ease; +} +#mdLightbox .md-lightbox-close:hover { + background: rgba(255, 255, 255, 0.25); +} +#mdLightbox .md-lightbox-caption { + position: absolute; + left: 0; + right: 0; + bottom: 1rem; + text-align: center; + color: #d1d5db; + font-size: 0.85rem; + padding: 0 2rem; +} + +/* ---------- responsive ---------- */ +@media (max-width: 640px) { + .md-body h1 { font-size: 1.6em; } + .md-body h2 { font-size: 1.35em; } + .md-body pre { padding: 0.85rem 0.9rem; } +} diff --git a/static/js/markdown.js b/static/js/markdown.js new file mode 100644 index 0000000..6325d33 --- /dev/null +++ b/static/js/markdown.js @@ -0,0 +1,322 @@ +/* + * 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); diff --git a/templates/admin/article_create.html b/templates/admin/article_create.html index 6a8994c..a5be71e 100644 --- a/templates/admin/article_create.html +++ b/templates/admin/article_create.html @@ -1,5 +1,6 @@ {{define "article_create"}} {{template "header" .}} +{{template "markdown_assets" .}}

{{.FormTitleText}}

@@ -123,6 +124,9 @@ var easyMDE = new EasyMDE({ spellChecker: false, autosave: { enabled: false }, placeholder: '{{index .Tr "article_content"}}', + previewRender: function (plainText, preview) { + return '
' + BlogMD.render(plainText) + '
'; + }, toolbar: [ 'bold', 'italic', 'heading', '|', 'quote', 'unordered-list', 'ordered-list', '|', diff --git a/templates/admin/comment_list.html b/templates/admin/comment_list.html index fdbe5ce..ff78adb 100644 --- a/templates/admin/comment_list.html +++ b/templates/admin/comment_list.html @@ -1,5 +1,6 @@ {{define "comment_list"}} {{template "header" .}} +{{template "markdown_assets" .}}

{{index .Tr "admin_comments_title"}}

@@ -51,7 +52,7 @@ ↗ {{.ArticleTitle}} {{end}} -
+
@@ -79,14 +80,10 @@
- - diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 8059fa6..8780379 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -108,6 +108,18 @@ {{end}} {{end}} +{{define "markdown_assets"}} +{{/* Markdown rendering assets: marked (pinned UMD build), DOMPurify, + highlight.js and the shared renderer + styles. Include on any page that + renders Markdown (articles, comments, editor previews). */}} + + + + + + +{{end}} + {{define "footer"}} diff --git a/templates/pages/article.html b/templates/pages/article.html index 7699838..ddb05b3 100644 --- a/templates/pages/article.html +++ b/templates/pages/article.html @@ -1,8 +1,6 @@ {{define "article"}} {{template "header" .}} - - - +{{template "markdown_assets" .}}
@@ -44,7 +42,7 @@
{{end}} -
+
{{if .CommentError}} @@ -144,21 +142,16 @@