/**
* email-comment.js — 客户端邮件评论加载器
* Fetch /comments/{post-id}.json,用 comment-renderer 的 HTML 结构渲染
*/
class EmailCommentLoader {
constructor() {
const isEn = document.documentElement.lang === 'en';
this.labels = {
title: isEn ? 'Email Comments' : '邮件评论',
intro_prefix: isEn ? 'Join the discussion via email: ' : '通过邮件参与讨论:',
send_aria: isEn ? 'Send a comment via email' : '通过邮件发送评论',
empty: isEn ? 'No email comments yet.' : '还没有邮件评论。',
reply: isEn ? 'Reply' : '回复',
reply_aria: isEn ? "Reply to {name}'s comment" : '回复 {name} 的评论',
expand: isEn ? 'Show more' : '展开',
hint: isEn
? 'Send an email to the address above to comment. Your name will be shown publicly, but your email address will not.'
: '发送邮件到上方地址即可评论。你的名字会公开显示,但邮箱地址不会。',
loading: isEn ? 'Loading comments...' : '加载评论中…',
error: isEn ? 'Failed to load comments' : '评论加载失败'
};
this.FOLD_THRESHOLD = 300;
this.init();
}
init() {
const containers = document.querySelectorAll('.email-comment-section[data-post-id]');
if (containers.length === 0) return;
containers.forEach(container => this.loadComments(container));
}
async loadComments(container) {
const postId = container.getAttribute('data-post-id');
const blogDomain = container.getAttribute('data-blog-domain');
if (!postId) return;
try {
const response = await fetch(`/comments/${postId}.json`);
if (!response.ok) {
if (response.status === 404) {
// 没有评论数据,渲染空状态
this.renderEmpty(container, postId, blogDomain);
return;
}
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.renderSection(container, data, postId, blogDomain);
} catch (err) {
console.error('[email-comment] Failed to load:', err);
this.renderError(container, postId, blogDomain);
}
}
renderSection(container, data, postId, blogDomain) {
const emailAddr = `post-${postId}@${blogDomain}`;
const tree = data.tree || [];
const count = data.count || 0;
let html = `<h3 class="email-comment-title">${this.labels.title} (<span class="email-comment-count">${count}</span>)</h3>`;
html += `<p class="email-comment-intro">${this.labels.intro_prefix}<a href="mailto:${this.esc(emailAddr)}" aria-label="${this.esc(this.labels.send_aria)}">${this.esc(emailAddr)}</a></p>`;
if (tree.length === 0) {
html += `<p class="email-comment-empty">${this.labels.empty}</p>`;
} else {
html += this.renderList(tree, 0, postId, blogDomain);
}
html += `<p class="email-comment-hint">${this.labels.hint}</p>`;
container.innerHTML = html;
container.classList.remove('email-comment-loading');
// 绑定折叠按钮事件
this.bindExpandButtons(container);
}
renderList(nodes, depth, postId, blogDomain) {
if (!nodes || nodes.length === 0) return '';
let html = `<ol class="email-comment-list" data-depth="${depth}">`;
for (const node of nodes) {
html += this.renderNode(node, depth, postId, blogDomain);
}
html += '</ol>';
return html;
}
renderNode(node, depth, postId, blogDomain) {
const replyAddr = `post-${postId}@${blogDomain}`;
const dateStr = this.formatDate(node.date);
const authorName = this.esc(node.from?.name || 'Anonymous');
const bodyHtml = this.renderBody(node.body);
const isFolded = (node.body || '').length > this.FOLD_THRESHOLD;
let html = '<li>';
html += `<article class="email-comment-item" id="comment-${this.simpleHash(node.id)}">`;
html += `<div class="email-comment-author">`;
html += `<span class="email-comment-author-name">${authorName}</span>`;
html += `<span class="email-comment-date">${dateStr}</span>`;
html += `<a class="email-comment-reply-link" href="mailto:${this.esc(replyAddr)}" aria-label="${this.esc(this.labels.reply_aria.replace('{name}', node.from?.name || 'Anonymous'))}">${this.labels.reply}</a>`;
html += '</div>';
if (isFolded) {
html += `<div class="email-comment-content email-comment-folded">${bodyHtml}`;
html += `<button class="email-comment-expand" type="button">${this.labels.expand}</button></div>`;
} else {
html += `<div class="email-comment-content">${bodyHtml}</div>`;
}
if (node.children && node.children.length > 0) {
html += this.renderList(node.children, depth + 1, postId, blogDomain);
}
html += '</article></li>';
return html;
}
renderBody(body) {
if (!body) return '';
let text = body
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
// URL 自动链接
text = text.replace(
/https?:\/\/[^\s<>"')\]]+/g,
url => `<a href="${url}" target="_blank" rel="noopener ugc">${url}</a>`
);
// \n → <br>
text = text.replace(/\n/g, '<br>');
return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(text) : text;
}
renderEmpty(container, postId, blogDomain) {
const emailAddr = `post-${postId}@${blogDomain}`;
container.innerHTML = `
<h3 class="email-comment-title">${this.labels.title} (<span class="email-comment-count">0</span>)</h3>
<p class="email-comment-intro">${this.labels.intro_prefix}<a href="mailto:${this.esc(emailAddr)}" aria-label="${this.esc(this.labels.send_aria)}">${this.esc(emailAddr)}</a></p>
<p class="email-comment-empty">${this.labels.empty}</p>
<p class="email-comment-hint">${this.labels.hint}</p>`;
container.classList.remove('email-comment-loading');
}
renderError(container, postId, blogDomain) {
const emailAddr = `post-${postId}@${blogDomain}`;
container.innerHTML = `
<h3 class="email-comment-title">${this.labels.title}</h3>
<p class="email-comment-intro">${this.labels.intro_prefix}<a href="mailto:${this.esc(emailAddr)}" aria-label="${this.esc(this.labels.send_aria)}">${this.esc(emailAddr)}</a></p>
<p class="email-comment-error">${this.labels.error}</p>
<p class="email-comment-hint">${this.labels.hint}</p>`;
container.classList.remove('email-comment-loading');
}
bindExpandButtons(container) {
container.querySelectorAll('.email-comment-expand').forEach(btn => {
btn.addEventListener('click', () => {
const content = btn.closest('.email-comment-content');
if (content) {
content.classList.toggle('email-comment-folded');
content.classList.toggle('email-comment-expanded');
btn.style.display = 'none';
}
});
});
}
formatDate(dateStr) {
try {
return new Date(dateStr).toLocaleDateString('zh-CN');
} catch {
return dateStr || '';
}
}
simpleHash(messageId) {
if (!messageId) return '0';
let hash = 0;
const str = messageId.replace(/[<>]/g, '');
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}
esc(str) {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new EmailCommentLoader());
} else {
new EmailCommentLoader();
}