Merge pull request '完善前端 Markdown 渲染:排版样式、代码高亮、标题锚点、图片灯箱、复制按钮' (#1) from dsh/go_blog:feat/markdown-rendering into main

Reviewed-on: kevin/go_blog#1
This commit is contained in:
2026-08-18 06:12:12 -04:00
11 changed files with 744 additions and 26 deletions
+1
View File
@@ -5,6 +5,7 @@
## 功能特性
- **文章管理** — 文章的创建、编辑、删除,支持标签分类、自定义发布时间和更新时间
- **Markdown 渲染** — 文章与评论支持 GFM Markdown(表格、任务列表、删除线),代码块语法高亮 + 一键复制,标题锚点链接,图片懒加载与灯箱预览
- **评论系统** — 文章评论功能,后台可审核(通过/拒绝/删除),支持评论设置
- **用户系统** — 用户注册、登录,角色分为管理员(admin)和作者(author
- **角色权限** — 管理员可访问后台管理面板;作者可管理自己的文章
+5
View File
@@ -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"
+3
View File
@@ -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))
+377
View File
@@ -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; }
}
+322
View File
@@ -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 <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">&times;</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);
+4
View File
@@ -1,5 +1,6 @@
{{define "article_create"}}
{{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-3xl mx-auto px-4 py-10">
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
@@ -123,6 +124,9 @@ var easyMDE = new EasyMDE({
spellChecker: false,
autosave: { enabled: false },
placeholder: '{{index .Tr "article_content"}}',
previewRender: function (plainText, preview) {
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
},
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
+3 -6
View File
@@ -1,5 +1,6 @@
{{define "comment_list"}}
{{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-6xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-6">
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "admin_comments_title"}}</h2>
@@ -51,7 +52,7 @@
↗ {{.ArticleTitle}}
</a>
{{end}}
<div class="comment-body mt-2 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Content}}"></div>
<div class="comment-body mt-2 text-sm text-gray-700 prose prose-sm max-w-none md-body" data-md="{{.Content}}"></div>
</div>
</div>
<div class="flex justify-end gap-3 mt-3 text-sm">
@@ -79,14 +80,10 @@
</div>
</section>
<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>
(function () {
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
var raw = el.getAttribute('data-md') || '';
var html = marked.parse(raw);
el.innerHTML = DOMPurify.sanitize(html);
BlogMD.renderInto(el, el.getAttribute('data-md') || '');
});
})();
</script>
+12
View File
@@ -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). */}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css">
<link rel="stylesheet" href="/static/css/markdown.css">
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js"></script>
<script src="/static/js/markdown.js"></script>
{{end}}
{{define "footer"}}
</main>
+12 -19
View File
@@ -1,8 +1,6 @@
{{define "article"}}
{{template "header" .}}
<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>
{{template "markdown_assets" .}}
<section class="max-w-3xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-6">
@@ -44,7 +42,7 @@
</div>
{{end}}
<div id="articleBody" class="prose max-w-none text-gray-800"></div>
<div id="articleBody" class="prose max-w-none text-gray-800 md-body"></div>
</article>
{{if .CommentError}}
@@ -144,21 +142,16 @@
</section>
<script>
// Article body
var md = {{.Article.Content}};
document.getElementById('articleBody').innerHTML = DOMPurify.sanitize(marked.parse(md));
// Article body: render Markdown client-side through the shared BlogMD
// pipeline (marked + DOMPurify + highlight.js, see /static/js/markdown.js).
BlogMD.renderInto(document.getElementById('articleBody'), {{.Article.Content}});
marked.setOptions({ mangle: false, headerIds: false });
function renderCommentBodies() {
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
if (el.dataset.rendered) return;
var raw = el.getAttribute('data-md') || '';
el.innerHTML = DOMPurify.sanitize(marked.parse(raw));
el.dataset.rendered = '1';
});
}
renderCommentBodies();
// Comments are rendered from the escaped data-md attribute.
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
if (el.dataset.rendered) return;
BlogMD.renderInto(el, el.getAttribute('data-md') || '');
el.dataset.rendered = '1';
});
// ---- Comment form: preview / emoji / reply ----
// Auto-dismiss the one-time success/pending notice after a few seconds.
@@ -198,7 +191,7 @@
var previewing = previewBox.classList.toggle('hidden') === false;
textarea.classList.toggle('hidden', previewing);
if (previewing) {
previewBox.innerHTML = DOMPurify.sanitize(marked.parse(textarea.value));
previewBox.innerHTML = BlogMD.render(textarea.value);
togglePreview.textContent = '{{index .Tr "comments_edit"}}';
} else {
togglePreview.textContent = '{{index .Tr "comments_preview"}}';
+1 -1
View File
@@ -17,7 +17,7 @@
{{if .Comment.IsPrivate}}<span class="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">{{index .Tr "comments_private_badge"}}</span>{{end}}
{{if eq .Comment.Status 0}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">{{index .Tr "comments_pending"}}</span>{{end}}
</div>
<div class="comment-body mt-1 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Comment.Content}}"></div>
<div class="comment-body mt-1 text-sm text-gray-700 prose prose-sm max-w-none md-body" data-md="{{.Comment.Content}}"></div>
<button type="button" class="reply-btn mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer"
data-id="{{.Comment.ID}}" data-name="{{.Comment.AuthorName}}">{{index .Tr "comments_reply"}}</button>
</div>
+4
View File
@@ -1,5 +1,6 @@
{{define "my_article_form"}}
{{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-4xl mx-auto px-4 py-12">
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
@@ -93,6 +94,9 @@ document.addEventListener('DOMContentLoaded', function() {
element: document.getElementById('content'),
spellChecker: false,
status: false,
previewRender: function (plainText, preview) {
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
},
toolbar: ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|",
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
});