Hugo 书影音引用块 Shortcodes
Hugo 博客中创建一个名为 quote 的 Shortcode,用于在文章中优雅地引用书籍、影视和音乐的语录。
Python脚本一键压缩Hugo博客图片
然而图片大小的压缩并没有解决这费劲的图片加载速度……莫非还是得要用图床?
iCloud+Obsidian+Git插件实现iOS端Hugo博客更新
实现思路 本博客通过 Hugo 搭建,使用 GitHub 托管,并借助 Cloudflare Pages 实现自动化构建。因此,在手机端将修改内容推送至 GitHub 仓库后,即可完成文章发布和修改操作。 考虑到手机输入的局限性,这套方案更适合书写 Moments 类型的内容,而非长篇文章。 <!DOCTYPE html> Responsive Image Step1-在iOS端Obsidian添加Vault并同步到iCloud 在 Obsidian 中创建一个新的 Vault,并将其保存至 iCloud 的指定目录,实现内容的云端同步。 <!DOCTYPE html> Responsive Image ...
hugo-Papermod添加瞬间Moments页面
<!DOCTYPE html> Responsive Image 实现功能: 标签筛选Waline 评论区显示每条 moment 评论数量 Step 1 - 添加 moments 页面模板 layouts/moments/list.html {{ define "main" }} <!-- 如果需要引入 moments.css,请保持路径一致或根据自己项目结构调整 --> {{ $css := resources.Get "css/extended/moments.css" | minify | fingerprint }} <link crossorigin="anonymous" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" rel="stylesheet" /> {{ $dateformat := .Params.DateFormat }} <article class="post-single"> <header class="page-header"> <!-- 可根据需求添加页面标题、描述等 --> <!-- <h1>{{ .Title }}</h1> --> </header> <div class="tags-filter"> <ul> <li><a href="#" class="tag-filter all-tags">全部</a></li> <!-- "全部"选项 --> {{ $tags := slice }} <!-- 用于存储所有标签 --> {{ range .Pages }} {{ range .Params.tags }} {{ if not (in $tags .) }} {{ $tags = $tags | append . }} {{ end }} {{ end }} {{ end }} <!-- 按字母顺序排序标签 --> {{ $tags = $tags | sort }} {{ range $tags }} <li><a href="#" class="tag-filter">{{ . }}</a></li> <!-- 标签项 --> {{ end }} </ul> </div> <div class="post-content"> <div class="moments-list"> {{ range .Pages }} {{ if .Content }} <!-- 卡片容器 --> <div class="moment-card"> <!-- 头部:头像 + 作者名 --> <div class="moment-header"> <div class="left-content"> <img src="{{ site.Params.label.avatar }}" alt="{{ site.Params.author }}" class="moment-avatar" > <span class="moment-author"> {{ site.Params.author }} </span> </div> </div> <!-- 动态主体内容(Hugo 渲染后的 .Content) --> <div class="moment-body"> <div class="moment-content-wrapper"> {{ .Content | safeHTML }} </div> <div class="moment-loading"> <div class="loading-spinner"></div> <div class="skeleton-content"> <div class="skeleton-line" style="width: 90%"></div> <div class="skeleton-line" style="width: 75%"></div> <div class="skeleton-line" style="width: 60%"></div> </div> </div> <div class="moment-error" style="display: none;"> <span>图片加载失败</span> <button onclick="retryLoad(this)">重试</button> </div> </div> <!-- 标签(如果有) --> {{ if .Params.tags }} <div class="moment-tags"> {{ range $tag := .Params.tags }} <span class="moment-tag">{{ $tag }}</span> {{ end }} </div> {{ end }} <!-- 底部:时间 + 评论按钮 --> <div class="moment-bottom"> <div class="moment-time"> <span> {{ .Param "date" | time.Format (default site.Params.DateFormat $dateformat) }} </span> </div> <!-- 如果没有 hideComment 参数,则显示评论按钮 --> {{ if not (.Param "hideComment") }} <button class="moment-comment-btn" onclick="showComment(this)" data-slug="{{ .Param "slug" }}" data-path="{{ .Param "slug" }}" > <!-- 评论图标 SVG --> <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M281.535354 387.361616c-31.806061 0-57.664646 26.763636-57.664647 59.733333 0 32.969697 25.858586 59.733333 57.664647 59.733334s57.664646-26.763636 57.664646-59.733334c0-33.09899-25.858586-59.733333-57.664646-59.733333z m230.529292 0c-31.806061 0-57.664646 26.763636-57.664646 59.733333 0 32.969697 25.729293 59.733333 57.664646 59.733334 31.806061 0 57.535354-26.763636 57.535354-59.733334 0-33.09899-25.858586-59.733333-57.535354-59.733333z m230.4 0c-31.806061 0-57.664646 26.763636-57.664646 59.733333 0 32.969697 25.858586 59.733333 57.664646 59.733334s57.664646-26.763636 57.664647-59.733334c-0.129293-33.09899-25.858586-59.733333-57.664647-59.733333z m115.2-270.222222H166.335354c-63.612121 0-115.2 53.527273-115.2 119.59596v390.981818c0 65.939394 52.751515 126.836364 117.785858 126.836363h175.579798c30.513131 32.581818 157.220202 149.979798 157.220202 149.979798 5.559596 5.818182 14.739394 5.818182 20.29899 0 0 0 92.832323-91.410101 153.212121-149.979798h179.717172c65.034343 0 117.785859-60.89697 117.785859-126.836363V236.606061c0.129293-65.939394-51.458586-119.466667-115.070708-119.466667z m57.535354 510.577778c0 32.969697-27.668687 67.620202-60.250505 67.620202H678.335354c-21.462626 0-40.727273 21.979798-40.727273 21.979798l-124.121212 114.941414-124.121212-114.941414s-23.660606-21.979798-43.830303-21.979798H168.921212c-32.581818 0-60.250505-34.650505-60.250505-67.620202V236.606061c0-32.969697 25.729293-59.733333 57.664647-59.733334h691.329292c31.806061 0 57.535354 26.763636 57.535354 59.733334v391.111111z m0 0"></path></svg> <!-- 评论按钮文字 --> <span class="comment-text">评论 ({{ .Params.comments_count | default "0" }})</span> </button> {{ end }} </div> <!-- 评论容器:点击按钮后会在这里渲染 Waline 评论 --> <div class="waline-container" id="waline-{{ .Param "slug" }}" data-path="{{ .Param "slug" }}" ></div> </div> {{ end }} {{ end }} </div> </div> </article> <!-- JavaScript 代码 --> <script> document.addEventListener('DOMContentLoaded', function() { // 图片懒加载和预加载处理 const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; const wrapper = img.closest('.moment-content-wrapper'); const loadingEl = wrapper.nextElementSibling; const errorEl = loadingEl.nextElementSibling; // 显示加载状态 loadingEl.style.display = 'flex'; errorEl.style.display = 'none'; // 创建新的Image对象用于预加载 const tempImg = new Image(); tempImg.onload = function() { img.src = img.dataset.src; img.classList.add('loaded'); loadingEl.style.display = 'none'; observer.unobserve(img); }; tempImg.onerror = function() { loadingEl.style.display = 'none'; errorEl.style.display = 'block'; }; tempImg.src = img.dataset.src; } }); }, { rootMargin: '50px 0px', threshold: 0.1 }); // 处理所有图片元素 document.querySelectorAll('.moment-content-wrapper').forEach(wrapper => { const loadingEl = wrapper.nextElementSibling; const images = wrapper.querySelectorAll('img'); if (images.length === 0) { loadingEl.style.display = 'none'; return; } let loadedImages = 0; images.forEach(img => { if (img.src) { img.dataset.src = img.src; img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // 透明占位图 imageObserver.observe(img); // 监听图片加载完成 img.onload = () => { loadedImages++; if (loadedImages === images.length) { loadingEl.style.display = 'none'; } }; } }); }); // 重试加载功能 window.retryLoad = function(button) { const errorEl = button.closest('.moment-error'); const loadingEl = errorEl.previousElementSibling; const wrapper = loadingEl.previousElementSibling; const img = wrapper.querySelector('img'); errorEl.style.display = 'none'; loadingEl.style.display = 'flex'; const tempImg = new Image(); tempImg.onload = function() { img.src = img.dataset.src; img.classList.add('loaded'); loadingEl.style.display = 'none'; }; tempImg.onerror = function() { loadingEl.style.display = 'none'; errorEl.style.display = 'block'; }; tempImg.src = img.dataset.src; }; // 新增分页相关变量 let currentPage = 1; const pageSize = {{ .Site.Params.moments.pageSize | default 8 }}; let filteredMoments = []; let isLoading = false; let isAllLoaded = false; let loadingTimeout = null; const loadingHint = document.createElement('div'); loadingHint.className = 'scroll-hint-container'; loadingHint.innerHTML = '<div class="loading-hint">加载更多...</div>'; document.querySelector('.moments-list').after(loadingHint); // 显示分页内容的方法 function displayMoments() { const end = currentPage * pageSize; const totalItems = filteredMoments.length; // 优化显示逻辑,只处理新增的内容 const start = (currentPage - 1) * pageSize; filteredMoments.slice(start, end).forEach(moment => { moment.style.display = 'block'; // 触发图片懒加载重新检查 moment.querySelectorAll('img[data-src]').forEach(img => { imageObserver.observe(img); }); }); // 更新底部提示 const hasMore = end < totalItems; isAllLoaded = !hasMore; if (totalItems === 0) { loadingHint.innerHTML = '<div class="end-divider">暂无内容</div>'; } else if (isAllLoaded) { loadingHint.innerHTML = '<div class="end-divider">———— · 已到底部 · ————</div>'; } else { loadingHint.innerHTML = '<div class="loading-hint">加载更多...</div>'; } loadingHint.style.display = 'block'; } // 优化的滚动事件处理 function checkScroll() { if (isLoading || isAllLoaded || filteredMoments.length === 0) return; const { scrollTop, scrollHeight, clientHeight } = document.documentElement; const threshold = 200; // 增加阈值,提前开始加载 const end = currentPage * pageSize; if (end < filteredMoments.length && scrollTop + clientHeight >= scrollHeight - threshold) { isLoading = true; loadingHint.innerHTML = '<div class="loading-hint"><div class="loading-spinner"></div>加载中...</div>'; // 清除之前的超时 if (loadingTimeout) { clearTimeout(loadingTimeout); } // 使用 requestAnimationFrame 和防抖优化性能 requestAnimationFrame(() => { loadingTimeout = setTimeout(() => { currentPage++; displayMoments(); isLoading = false; loadingTimeout = null; }, 300); }); } } // 点击标签时筛选逻辑 const tags = document.querySelectorAll('.tag-filter'); // 获取所有标签 const moments = document.querySelectorAll('.moment-card'); // 获取所有 moment 卡片 const allTags = document.querySelector('.all-tags'); // 获取"全部"按钮 const momentTags = document.querySelectorAll('.moment-tag'); // 获取所有卡片内的标签 // 默认选中"全部"标签 if (allTags) { allTags.classList.add('selected'); } // 点击标签时进行筛选 tags.forEach(tag => { tag.addEventListener('click', function(e) { e.preventDefault(); const selectedTag = tag.textContent.trim(); filteredMoments = Array.from(moments).filter(moment => { const momentTags = moment.querySelectorAll('.moment-tag'); return selectedTag === '全部' || Array.from(momentTags).some(t => t.textContent === selectedTag); }); currentPage = 1; displayMoments(); window.scrollTo(0, 0); // 筛选后回到顶部 tags.forEach(t => t.classList.remove('selected')); tag.classList.add('selected'); }); }); // 为卡片内的标签添加点击事件 momentTags.forEach(tag => { tag.style.cursor = 'pointer'; tag.addEventListener('click', function() { const tagText = this.textContent.trim(); // 找到对应的顶部标签并触发点击 tags.forEach(headerTag => { if (headerTag.textContent.trim() === tagText) { headerTag.click(); } }); }); }); // 初始加载 filteredMoments = Array.from(moments); displayMoments(); // 确保提示容器正确插入 const existingHint = document.querySelector('.scroll-hint-container'); if (!existingHint) { const hintContainer = document.createElement('div'); hintContainer.className = 'scroll-hint-container'; document.querySelector('.moments-list').after(hintContainer); } window.addEventListener('scroll', checkScroll); }); </script> <!-- 在页面底部引入 Waline 评论脚本并初始化 --> <script type="module"> import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js'; const walineParams = { /* 这里根据你自己的 Waline 配置进行调整 */ serverURL: '{{ .Site.Params.waline.serverURL }}', lang: '{{ .Site.Params.waline.lang | default "zh-CN" }}', visitor: '{{ .Site.Params.waline.visitor | default "匿名者" }}', emoji: [ {{- range .Site.Params.waline.emoji }} '{{ . }}', {{- end }} ], requiredMeta: [ {{- range .Site.Params.waline.requiredMeta }} '{{ . }}', {{- end }} ], locale: { admin: '{{ .Site.Params.waline.locale.admin | default "作者本人" }}', placeholder: '{{ .Site.Params.waline.locale.placeholder | default "🍗所以我配有一条评论吗!" }}', }, dark: '{{ .Site.Params.waline.dark | default "html.dark" }}', }; // 点击"添加评论"按钮时,显示对应卡片下的评论区 window.showComment = function(element) { const slug = element.getAttribute('data-slug'); const path = element.getAttribute('data-path'); const commentElement = document.getElementById('waline-' + slug); // 如果已激活则清空 if (commentElement.classList.contains('active')) { commentElement.classList.remove('active'); commentElement.innerHTML = ''; return; } // 移除其它所有已激活评论区 const allComments = document.querySelectorAll('.waline-container'); allComments.forEach(el => { el.classList.remove('active'); el.innerHTML = ''; }); // 激活当前评论区 commentElement.classList.add('active'); // 初始化 Waline init({ el: commentElement, serverURL: walineParams.serverURL, lang: walineParams.lang, visitor: walineParams.visitor, emoji: walineParams.emoji, requiredMeta: walineParams.requiredMeta, locale: walineParams.locale, path: path, dark: walineParams.dark, }); } </script> <!-- 获取评论数 --> <script> document.addEventListener('DOMContentLoaded', function() { // 获取所有评论按钮 const commentBtns = document.querySelectorAll('.moment-comment-btn'); // 确保有找到评论按钮 if (commentBtns.length > 0) { commentBtns.forEach(button => { const slug = button.getAttribute('data-slug'); // 获取按钮对应的 slug const commentText = button.querySelector('.comment-text'); // 获取按钮中的评论文本 const serverURL = '{{ .Site.Params.waline.serverURL }}'; // 获取 Waline 服务器地址 // 输出调试信息,查看是否有多个按钮 console.log(`Processing button with slug: ${slug}`); if (slug && commentText) { // 假设你有一个获取评论数量的 API 或接口 fetch(`${serverURL}/api/comment?type=count&url=${slug}`) .then(response => response.json()) .then(data => { if (commentText) { // 从 API 返回的数据中提取评论数 const commentCount = data.data && data.data[0] ? data.data[0] : 0; // 如果没有数据或评论数,默认为 0 // 输出调试信息,查看评论数 console.log(`Fetched comment count: ${commentCount}`); // 更新评论按钮上的评论数量 commentText.textContent = `评论 (${commentCount})`; // 更新评论数 } }) .catch(error => { console.error('Error fetching comment count:', error); if (commentText) { // 如果 API 请求失败,保持评论数为 0 commentText.textContent = `评论 (0)`; } }); } else { console.error('Slug or commentText missing for button:', button); } }); } else { console.error('No comment buttons found'); } }); </script> {{ end }} Step2 - Build options content/mements/_index.md ...
Hugo-PaperMod在首页添加热力图
<!DOCTYPE html> Responsive Image 实现功能: 根据颜色深浅表现当日字数的多少鼠标悬停展示当日总字数、文章数和瞬间数点击弹出当天写的文章列表 Step 1 - 使用脚本生成 json 文件 heatmap.py(我放在网站项目根目录) import os import re import json import jieba import frontmatter from datetime import datetime, date from dateutil.parser import parse as parse_date from collections import defaultdict # 配置 POSTS_DIR = 'content/posts' # Hugo posts 目录 MOMENTS_DIR = 'content/moments' # Hugo moments 目录 OUTPUT_FILE = 'static/data/posts_heatmap.json' # 输出 JSON 文件路径 if os.path.exists(OUTPUT_FILE): os.remove(OUTPUT_FILE) # 初始化数据结构: key=日期(字符串),value=字典 heatmap_data = defaultdict(lambda: { 'posts': [], 'moments': [], 'totalWords': 0.0, 'momentCount': 0 }) def compute_words_count_by_jieba(markdown_content): """ 示例: 使用 jieba 对文本进行分词,统计词数并换算成“千字”。 """ text = re.sub(r'(```.+?```)', ' ', markdown_content, flags=re.S) text = re.sub(r'(`[^`]+`)', ' ', text) text = re.sub(r'\[[^\]]+\]\([^\)]+\)', ' ', text) tokens = list(jieba.cut(text, cut_all=False)) tokens = [tok for tok in tokens if tok.strip()] # 去掉纯空白 return round(len(tokens)/1000.0, 2) def convert_dates(obj): """ 递归地将字典或列表中的 date/datetime 对象转换为字符串。 防止写入 JSON 时出现 “Object of type date is not JSON serializable”。 """ if isinstance(obj, dict): return {k: convert_dates(v) for k, v in obj.items()} elif isinstance(obj, list): return [convert_dates(item) for item in obj] elif isinstance(obj, (date, datetime)): return obj.strftime('%Y-%m-%d') else: return obj def process_files(directory, category): """ 遍历指定目录,读取 Markdown 文件并插入 heatmap_data。 category 为 'posts' 或 'moments'。 """ if not os.path.exists(directory): print(f"目录不存在: {directory}") return for root, dirs, files in os.walk(directory): for filename in files: if category == 'posts': # 仅处理 'index.md' + 排除 _index.md if filename == 'index.md' and not filename.startswith('_'): file_path = os.path.join(root, filename) else: continue else: # moments: 处理 .md 文件,排除 _index.md if filename.endswith('.md') and not filename.startswith('_'): file_path = os.path.join(root, filename) else: continue try: with open(file_path, 'r', encoding='utf-8') as f: post = frontmatter.load(f) date_field = post.get('date') if not date_field: print(f"文件 {file_path} 缺少 'date' 字段,跳过。") continue # 统一解析 date 并存成字符串 try: # 先转成字符串,再用 parse_date post_date = parse_date(str(date_field)) date_str = post_date.strftime('%Y-%m-%d') except (ValueError, TypeError) as e: print(f"无法解析文件 {file_path} 的日期:{date_field} => {e}") continue # 计算字数 words_count = compute_words_count_by_jieba(post.content) # 获取 slug slug = post.get('slug') if not slug: if category == 'posts': slug = os.path.basename(root) else: slug = os.path.splitext(filename)[0] # 获取 title if category == 'moments': title = slug # moments 不关心 title => 用 slug else: title = post.get('title', '未命名') # 链接(仅 posts) link = f"/posts/{slug}/" if category == 'posts' else "" # 初始化 date_str if date_str not in heatmap_data: heatmap_data[date_str] = { 'posts': [], 'moments': [], 'totalWords': 0.0, 'momentCount': 0 } if category == 'posts': heatmap_data[date_str]['posts'].append({ 'title': title, 'words': words_count, 'link': link }) else: # moments heatmap_data[date_str]['moments'].append({ 'title': title, 'words': words_count }) except Exception as e: print(f"处理文件 {file_path} 时发生错误:{e}") def main(): # 分别处理 posts 和 moments process_files(POSTS_DIR, 'posts') process_files(MOMENTS_DIR, 'moments') # 统计 totalWords 和 momentCount for date_str, data in heatmap_data.items(): total = 0.0 total += sum(x['words'] for x in data['posts']) total += sum(x['words'] for x in data['moments']) data['totalWords'] = round(total, 2) data['momentCount'] = len(data['moments']) # 将 defaultdict 转为普通字典 heatmap_dict = dict(heatmap_data) for k in heatmap_dict: heatmap_dict[k] = dict(heatmap_dict[k]) # 在写出之前,递归地将 date/datetime 全转成字符串 # 因为 heatmap_dict 的 key 已经是字符串 date_str,所以不需特地改它们 # 但为保险起见,依然可以把里面的任何残余 date 转掉 final_data = convert_dates(heatmap_dict) # 写入 JSON os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) try: with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: json.dump(final_data, f, ensure_ascii=False, indent=2) print(f"\n已成功生成 {OUTPUT_FILE}") except Exception as e: print(f"写入 JSON 文件时出错: {e}") if __name__ == "__main__": main() 生成的static/data/posts_heatmap.json应该类似这样: ...
在Hugo-PaperMod中加入Waline评论区
❗️ 20250325更新: 本文已过时,请使用主题Drifting-PaperMod,参考文档Drifting-PaperMod主题文档 Waline服务端部署 LeanCloud 设置 参考Waline官方文档: LeanCloud 设置 (数据库) Vercel部署 参考Waline官方文档: Vercel 部署 (服务端) 绑定域名(可选) 参考Waline官方文档: 绑定域名 (可选) 在Hugo-PaperMod中引入Waline HTML 引入 (客户端) 在layouts/partials文件夹下新增comments.html: layouts/partials/comments.html <!-- layouts/partials/comments.html --> {{- if .Site.Params.comments }} <!-- 评论容器 --> <div class="waline-container" data-path="{{ .Permalink | relURL }}"></div> <link href="https://unpkg.com/@waline/client@v3/dist/waline.css" rel="stylesheet" /> <!-- 初始化 Waline 的脚本 --> <script> document.addEventListener("DOMContentLoaded", () => { // 初始化 Waline const walineInit = () => { import('https://unpkg.com/@waline/client@v3/dist/waline.js').then(({ init }) => { const walineContainers = document.querySelectorAll('.waline-container[data-path]'); walineContainers.forEach(container => { if (!container.__waline__) { const path = container.getAttribute('data-path'); container.__waline__ = init({ el: container, serverURL: '{{ .Site.Params.waline.serverURL }}', lang: '{{ .Site.Params.waline.lang }}', visitor: '{{ .Site.Params.waline.visitor | default "匿名者" }}', emoji: [ {{- range .Site.Params.waline.emoji }} '{{ . }}', {{- end }} ], requiredMeta: [ {{- range .Site.Params.waline.requiredMeta }} '{{ . }}', {{- end }} ], locale: { admin: '{{ .Site.Params.waline.locale.admin }}', placeholder: '{{ .Site.Params.waline.locale.placeholder }}', }, path: path, dark: '{{ .Site.Params.waline.dark | default "body.dark" }}', }); } }); }).catch(error => { console.error("Waline 初始化失败:", error); }); }; walineInit(); }); </script> {{- end }} 修改hugo.yaml配置 toml文件自行改写。 在params下: ...
Obsidian+github+clouflarepages的Hugo一体式发布流程
<!DOCTYPE html> Responsive Image Cloudflare Pages 改成了使用 Vercel 也可以,部署步骤大同小异。 为什么使用 obsidian:Obsidian 有很多可用的插件,比如 excalidraw 这种流程图软件等等,可以一站实现所有的写文流程,不用再切换其他软件做图; 为什么使用 cloudflare pages:有现成的 Hugo 部署方案,结合 Github 可以实现自动化部署; QuickAdd 有什么作用:可以使用快捷键将 posts 添加到想要的位置,并且添加 front matter 的 yaml 模版; ...
使用1Panel面板搭建本地书库Calibre-Web
calibre-web 初始账号信息: 账号:admin 密码:admin123 Calibre-Web 项目介绍 使用 1Panel 安装 Calibre-Web 安装 1Panel 安装方法参考官网:1Panel <!DOCTYPE html> Responsive Image 应用商店一键安装 Calibre-Web 访问 ip:端口,端口为应用服务端口: <!DOCTYPE html> Responsive Image ...