Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3a875403 | ||
|
|
5f21c73044 | ||
|
|
83866f6de2 |
@@ -5,6 +5,7 @@
|
||||
## 功能特性
|
||||
|
||||
- **文章管理** — 文章的创建、编辑、删除,支持标签分类、自定义发布时间和更新时间
|
||||
- **Markdown 渲染** — 文章与评论支持 GFM Markdown(表格、任务列表、删除线),代码块语法高亮 + 一键复制,标题锚点链接,图片懒加载与灯箱预览
|
||||
- **评论系统** — 文章评论功能,后台可审核(通过/拒绝/删除),支持评论设置
|
||||
- **用户系统** — 用户注册、登录,角色分为管理员(admin)和作者(author)
|
||||
- **角色权限** — 管理员可访问后台管理面板;作者可管理自己的文章
|
||||
@@ -119,6 +120,9 @@ go_blog/
|
||||
│ └── upload_validator.go # 上传文件校验
|
||||
├── i18n/
|
||||
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
|
||||
├── static/
|
||||
│ ├── css/markdown.css # Markdown 排版样式(go:embed 编入二进制)
|
||||
│ └── js/markdown.js # 前端 Markdown 渲染器(BlogMD)
|
||||
├── templates/
|
||||
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
|
||||
│ ├── pages/
|
||||
|
||||
@@ -40,6 +40,7 @@ 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"
|
||||
# static 资源已通过 go:embed 编入二进制,无需单独拷贝
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
chmod 0755 "${INSTALL_DIR}"
|
||||
find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -17,6 +20,13 @@ import (
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// staticFiles embeds the static assets (Markdown CSS/JS) into the binary so a
|
||||
// deployment only needs to replace the executable — no separate static
|
||||
// directory has to be copied to the server.
|
||||
//
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
// 0. Parse command-line flags.
|
||||
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
|
||||
@@ -49,6 +59,13 @@ func main() {
|
||||
// 6. Serve uploaded files (avatars etc.) from the storage path.
|
||||
router.Static("/uploads", cfg.Path)
|
||||
|
||||
// 6b. Serve bundled static assets (embedded into the binary).
|
||||
staticFS, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open embedded static assets: %v", err)
|
||||
}
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
|
||||
// 6. Global session middleware.
|
||||
router.Use(sessions.Sessions("blog_session", store))
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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">×</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);
|
||||
@@ -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', '|',
|
||||
|
||||
@@ -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,15 @@
|
||||
</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);
|
||||
if (window.BlogMD) {
|
||||
BlogMD.renderInto(el, raw);
|
||||
} else {
|
||||
el.textContent = raw;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,31 @@
|
||||
</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).
|
||||
// If the renderer failed to load, fall back to plain text so the article
|
||||
// is never blank.
|
||||
(function () {
|
||||
var body = document.getElementById('articleBody');
|
||||
var content = {{.Article.Content}};
|
||||
if (window.BlogMD) {
|
||||
BlogMD.renderInto(body, content);
|
||||
} else {
|
||||
body.textContent = 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;
|
||||
var raw = el.getAttribute('data-md') || '';
|
||||
if (window.BlogMD) {
|
||||
BlogMD.renderInto(el, raw);
|
||||
} else {
|
||||
el.textContent = raw;
|
||||
}
|
||||
el.dataset.rendered = '1';
|
||||
});
|
||||
|
||||
// ---- Comment form: preview / emoji / reply ----
|
||||
// Auto-dismiss the one-time success/pending notice after a few seconds.
|
||||
@@ -198,7 +206,11 @@
|
||||
var previewing = previewBox.classList.toggle('hidden') === false;
|
||||
textarea.classList.toggle('hidden', previewing);
|
||||
if (previewing) {
|
||||
previewBox.innerHTML = DOMPurify.sanitize(marked.parse(textarea.value));
|
||||
if (window.BlogMD) {
|
||||
previewBox.innerHTML = BlogMD.render(textarea.value);
|
||||
} else {
|
||||
previewBox.textContent = textarea.value;
|
||||
}
|
||||
togglePreview.textContent = '{{index .Tr "comments_edit"}}';
|
||||
} else {
|
||||
togglePreview.textContent = '{{index .Tr "comments_preview"}}';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user