项目概述
将 cflow(基于 memos 魔改的碎片笔记工具)的公开卡片功能嵌入到 Quartz 博客中,实现:
- 碎碎念列表页
/c/- 展示所有公开卡片 - 卡片详情页
/c/3251- 点击卡片编号可查看单卡片详情 - JSON 数据源 - 从本地 JSON 文件加载数据,无需依赖 cflow API
最终效果
- 访问地址:https://blog.brmys.cn/c/
- 支持标签筛选、加载更多、暗色模式
- 卡片详情页有返回按钮和导航按钮
实现步骤
1. 源文件准备
碎碎念前端源码位于:
/Users/bairimengyushi/苏苏OB知识库/Obsidian blog/白日梦与诗的碎碎念/
├── index.html # 主页面
├── assets/
│ ├── css/ # 样式文件
│ ├── js/
│ │ └── main.js # 核心逻辑
│ └── img/ # 图片资源
2. 同步脚本 scripts/sync-memos.sh
创建同步脚本,将源文件复制到 public/c/ 目录:
#!/bin/bash
set -e
SOURCE_DIR="./白日梦与诗的碎碎念"
TARGET_C_DIR="./public/c"
JSON_SOURCE="../content/4 Archives/往年输出/cflow-memos.json"
JSON_TARGET="./public/cflow-memos.json"
# 清空并复制文件
rm -rf "$TARGET_C_DIR"
mkdir -p "$TARGET_C_DIR"
cp -R "$SOURCE_DIR/"* "$TARGET_C_DIR/"
# 清理不需要的文件
find "$TARGET_C_DIR" -name '.DS_Store' -delete 2>/dev/null || true
find "$TARGET_C_DIR" -name '*.md' -delete 2>/dev/null || true
# 修改配置为 JSON 数据源
sed -i '' "/var memos = {/a\\
dataSource: 'json',\\
jsonUrl: '/cflow-memos.json'," "$TARGET_C_DIR/index.html"
# 修改资源路径为绝对路径
sed -i '' 's|href="./assets/|href="/c/assets/|g' "$TARGET_C_DIR/index.html"
sed -i '' 's|src="./assets/|src="/c/assets/|g' "$TARGET_C_DIR/index.html"
# 复制 JSON 数据文件
cp "$JSON_SOURCE" "$JSON_TARGET"
# 创建 Cloudflare Pages 重定向规则
cat > "./public/_redirects" << 'EOF'
/c/:id /c/index.html 200
EOF3. 单卡片详情页功能
在 main.js 中添加单卡片模式检测:
document.addEventListener('DOMContentLoaded', function() {
// 检测路径格式: /c/3251
const pathname = window.location.pathname;
const memoIdMatch = pathname.match(/\/c\/(\d+)$/);
if (memoIdMatch) {
const memoId = memoIdMatch[1];
console.log('单卡片详情页模式,卡片ID:', memoId);
// 设置单卡片模式标志
window.singleMemoMode = true;
window.singleMemoId = memoId;
// 隐藏不需要的元素
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) loadMoreBtn.style.display = 'none';
const totalDiv = document.querySelector('.total');
if (totalDiv) totalDiv.style.display = 'none';
const tagFilter = document.getElementById('tag-filter');
if (tagFilter) tagFilter.style.display = 'none';
// 添加返回按钮
const buttonContainer = document.querySelector('.button-container');
if (buttonContainer) {
const backBtn = document.createElement('a');
backBtn.href = '/c/';
backBtn.className = 'back-to-all';
backBtn.innerHTML = '<svg>...</svg> 返回列表';
buttonContainer.parentNode.insertBefore(backBtn, buttonContainer.nextSibling);
}
}
});在 loadMemosData 函数中处理单卡片模式:
// 单卡片详情页模式:只返回指定ID的卡片
if (window.singleMemoMode && window.singleMemoId) {
const singleMemo = allFlatMemos.find(m => m.id == window.singleMemoId);
if (singleMemo) {
return {
data: [singleMemo],
hasMore: false,
total: 1
};
}
}4. 解决 Quartz SPA 路由冲突(关键)
问题:Quartz 的 SPA 路由会拦截所有本地链接,导致点击 /c/3251 时被错误处理,跳转回 /c/。
解决方案:修改 quartz/components/scripts/spa.inline.ts,让 /c/* 路径跳过 SPA 路由:
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
if (!isElement(target)) return
if (target.attributes.getNamedItem("target")?.value === "_blank") return
const a = target.closest("a")
if (!a) return
if ("routerIgnore" in a.dataset) return
const { href } = a
if (!isLocalUrl(href)) return
// 碎碎念页面 /c/* 不使用 SPA 路由,直接使用浏览器原生导航
const url = new URL(href)
if (url.pathname.startsWith("/c/") || url.pathname === "/c") return
return { url, scroll: "routerNoscroll" in a.dataset ? false : undefined }
}5. Cloudflare Pages 配置
创建 public/_redirects 文件实现 URL 重写:
/c/:id /c/index.html 200
这样访问 /c/3251 时,Cloudflare 会返回 /c/index.html,由前端 JavaScript 解析 URL 并加载对应卡片。
使用的命令
本地开发测试
cd "Obsidian blog"
# 同步碎碎念文件
npm run sync-memos
# 或
bash scripts/sync-memos.sh
# 构建 Quartz
npm run build
# 或
npx quartz build
# 本地预览
npm run serve
# 或
npx quartz build --serveGit 操作
# 查看状态
git status
# 回退到指定版本(调试时使用)
git checkout 3bbdfe09a3c57b665635a36c86d446f796d6c640
# 返回最新版本
git checkout main
# 提交修改
git add quartz/components/scripts/spa.inline.ts
git commit -m "fix: 让 Quartz SPA 路由忽略 /c/* 路径"
git pushpackage.json 脚本
{
"scripts": {
"sync-memos": "bash scripts/sync-memos.sh",
"build": "npx quartz build",
"serve": "npx quartz build --serve"
}
}遇到的问题及解决
问题 1:rsync 命令在 Cloudflare 不可用
错误:rsync: command not found
解决:将 rsync -av --delete 替换为 cp -R + find -delete
问题 2:本地正常但 Cloudflare 跳转失败
现象:本地 http://localhost:8080/c/3251 正常显示详情页,但部署到 Cloudflare 后点击卡片编号会跳转回 /c/
原因:Quartz 的 SPA 路由(spa.inline.ts 编译后的 postscript.js)会拦截所有本地链接,通过 AJAX 加载页面内容。对于 /c/3251 这样的路径,SPA 尝试加载失败后会 fallback 到 /c/
解决:修改 spa.inline.ts,在 getOpts 函数中添加判断,让 /c/* 路径返回 undefined,从而跳过 SPA 路由,使用浏览器原生导航
问题 3:详情页没有返回按钮
解决:在源文件 白日梦与诗的碎碎念/assets/js/main.js 中添加返回按钮逻辑(不是 public/c/ 中的文件,因为每次 sync 会被覆盖)
问题 4:Cloudflare Pages 上 _redirects 不生效(2025-12-03 最终解决)
现象:本地通过 npm run build && npm run serve 可以正常访问 /c/3030 详情页,但部署到 Cloudflare Pages 后点击卡片编号返回 404。
排查过程:
- 检查发现
_redirects文件在 Cloudflare 上返回 404,说明文件没有被正确部署 - 发现 Quartz 构建时会先清空整个
public/目录(quartz/build.ts第 70 行:await rm(output, { recursive: true, force: true })) - 尝试将
_redirects创建移到sync-memos.sh脚本末尾,但仍然不生效 - 怀疑是 Linux 环境下
sed命令失败导致脚本提前退出
最终解决方案:使用 Cloudflare Pages Functions 处理路由重写
创建 functions/c/[id].js 文件:
// Cloudflare Pages Function: 处理 /c/:id 路由
// 将 /c/3030 这样的请求重写到 /c/index.html
export async function onRequest(context) {
const { request, env } = context;
const url = new URL(request.url);
// 检查是否是数字 ID
const id = context.params.id;
if (/^\d+$/.test(id)) {
// 重写到 /c/index.html
const newUrl = new URL('/c/index.html', url.origin);
// 获取 index.html 内容
const response = await env.ASSETS.fetch(newUrl);
// 返回 index.html 内容,但保持原始 URL
return new Response(response.body, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
// 其他请求正常处理
return context.next();
}原理:
- Cloudflare Pages Functions 会自动识别
functions/目录下的文件 [id].js是动态路由,匹配/c/:id格式的请求- 当访问
/c/3030时,Function 检测到是数字 ID,返回/c/index.html的内容 - 前端 JavaScript 检测 URL 路径,只显示对应 ID 的卡片
为什么 _redirects 不生效:
- Cloudflare Pages 的
_redirects文件需要在构建输出目录的根目录 - 虽然
sync-memos.sh脚本创建了_redirects,但可能因为构建环境差异(macOS vs Linux)导致脚本执行异常 - Pages Functions 是更可靠的方案,不依赖文件系统
文件结构
Obsidian blog/
├── 白日梦与诗的碎碎念/ # 源文件(手动维护)
│ ├── index.html
│ └── assets/
├── functions/ # Cloudflare Pages Functions
│ └── c/
│ └── [id].js # 处理 /c/:id 路由重写
├── public/
│ ├── c/ # 构建输出(由 sync-memos.sh 生成)
│ │ ├── index.html
│ │ └── assets/
│ ├── cflow-memos.json # JSON 数据文件
│ └── _redirects # Cloudflare 重定向规则(备用)
├── quartz/
│ └── components/
│ └── scripts/
│ └── spa.inline.ts # SPA 路由(已修改忽略 /c/*)
├── scripts/
│ └── sync-memos.sh # 同步脚本
└── serve.json # 本地开发服务器配置(URL 重写)
相关提交
| 提交 | 说明 |
|---|---|
3af399b4 | feat: 添加 Cloudflare Pages Function 处理 /c/:id 路由(最终解决方案) |
16eff391 | feat: 添加碎碎念单卡片详情页路由支持 |
c96dec6e | fix: 让 Quartz SPA 路由忽略 /c/* 路径 |
3bbdfe09 | 之前可用的版本(用于调试回退) |
总结
这个项目的核心难点有两个:
1. Quartz SPA 路由与独立前端页面的冲突
Quartz 默认会拦截所有本地链接进行 SPA 导航,而碎碎念页面是一个独立的前端应用,需要浏览器原生导航才能正常工作。
解决方案:修改 quartz/components/scripts/spa.inline.ts,让 /c/* 路径跳过 SPA 处理。
2. Cloudflare Pages 路由重写
单卡片详情页(如 /c/3030)需要服务器返回 /c/index.html,由前端 JavaScript 解析 URL 并加载对应卡片。
尝试过的方案:
_redirects文件 - 在 Cloudflare 构建环境中不生效- 修改
sync-memos.sh脚本位置 - 仍然不生效
最终解决方案:使用 Cloudflare Pages Functions(functions/c/[id].js),这是最可靠的方式,不依赖文件系统和构建脚本。
经验教训
- 本地开发环境和生产环境(Cloudflare Pages)的行为可能不同
_redirects文件虽然简单,但在复杂构建流程中可能不可靠- Cloudflare Pages Functions 是处理动态路由的最佳方案
- 调试时要检查文件是否真正部署到了生产环境(通过直接访问
/_redirects等方式验证)
功能优化记录(2025-12-03)
更新概述
本次更新对碎碎念功能进行了全面优化,包括字体、标签识别、内容过滤、分页和搜索功能。
1. 添加霞鹜文楷字体
目标:统一博客字体风格,匹配 echo-noise 项目的视觉效果
实现:在 白日梦与诗的碎碎念/index.html 中添加 LXGW WenKai Screen 字体 CDN:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-webfont@1.1.0/style.css" />效果:页面使用霞鹜文楷屏幕阅读版字体,提升中文阅读体验
2. 修复标签识别正则表达式
问题:标签在行尾或标点符号前时无法被正确识别为可点击标签,显示为纯文本
原因:原正则表达式 /#([^\s#]+?) /g 要求标签后必须有空格
解决:修改 白日梦与诗的碎碎念/assets/js/main.js 中的 TAG_REG:
// 修改前
const TAG_REG = /#([^\s#]+?) /g;
// 修改后
const TAG_REG = /#([^\s#<>]+?)(?=\s|$|<|,|,|。|!|?|;|:|、)/g;效果:支持以下场景的标签识别
- 行尾标签:
这是内容 #cflow - 标点前标签:
这是内容 #cflow,继续内容 - 多个标签:
#标签1 #标签2 #标签3
3. 移除滴答清单引用块
目标:清理从滴答清单同步过来的引用块,保持卡片内容简洁
实现:三重过滤机制
3.1 插件层过滤
修改 .obsidian/plugins/cflow-sync/main.js 的 syncToBlogJson() 方法:
// 清理滴答清单引用块并重新提取标签
const content = await this.app.vault.adapter.read(fullPath);
const data = JSON.parse(content);
for (const dateGroup of data.memos || []) {
for (const memo of dateGroup.items || []) {
if (memo.content && memo.content.includes('来自滴答清单')) {
// 移除引用块
memo.content = memo.content.replace(/\n*> 来自滴答清单[^\n]*(?:\n>[^\n]*)*/g, '');
// 清理多余空行
memo.content = memo.content.replace(/\n{3,}/g, '\n\n').trim();
// 重新提取标签(因为可能包含滴答清单URL)
memo.tags = this.extractTagsFromContent(memo.content);
}
}
}3.2 同步脚本过滤
在 scripts/sync-memos.sh 中添加 Python 清理脚本:
# 清理滴答清单引用块
python3 -c "
import json
import re
with open('$JSON_TARGET', 'r', encoding='utf-8') as f:
data = json.load(f)
# 匹配 Markdown 格式的滴答清单引用块
pattern = r'\\\\n*> 来自滴答清单[^\\\\n]*(?:\\\\n>[^\\\\n]*)*'
count = 0
for date_group in data.get('memos', []):
for memo in date_group.get('items', []):
if 'content' in memo and '来自滴答清单' in memo['content']:
original = memo['content']
memo['content'] = re.sub(pattern, '', memo['content'])
if original != memo['content']:
count += 1
with open('$JSON_TARGET', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f' ✓ 已清理 {count} 条滴答清单引用')
"3.3 前端渲染过滤
在 白日梦与诗的碎碎念/assets/js/main.js 的 Markdown 解析后添加过滤:
// 解析 Markdown
let parsedContent = marked.parse(content);
// 移除滴答清单引用块(HTML格式)
parsedContent = parsedContent.replace(/<blockquote>[\s\S]*?来自滴答清单[\s\S]*?<\/blockquote>/gi, '');效果:彻底移除所有滴答清单引用块,包括标签筛选视图中的卡片
4. 实现完整分页功能
目标:优化卡片浏览体验,支持上一页/下一页/跳转/总页数显示
4.1 UI 布局优化
修改 白日梦与诗的碎碎念/index.html,将分页元素整合到一行:
<div class="pagination-container">
<button id="prev-page-btn" class="pagination-btn">上一页</button>
<span class="page-info">
第 <span id="current-page">1</span> 页 / 共 <span id="total-pages">1</span> 页
</span>
<button id="next-page-btn" class="pagination-btn">下一页</button>
<div class="page-jump">
跳转到 <input type="number" id="target-page" min="1" /> 页
<button id="jump-btn">跳转</button>
</div>
</div>4.2 分页逻辑实现
在 白日梦与诗的碎碎念/assets/js/main.js 中添加分页函数:
// 更新分页UI
function updatePaginationUI() {
const currentPageSpan = document.getElementById('current-page');
const totalPagesSpan = document.getElementById('total-pages');
const prevBtn = document.getElementById('prev-page-btn');
const nextBtn = document.getElementById('next-page-btn');
if (currentPageSpan) currentPageSpan.textContent = currentPage;
if (totalPagesSpan) totalPagesSpan.textContent = totalPages;
// 控制按钮可用性
if (prevBtn) prevBtn.disabled = (currentPage <= 1);
if (nextBtn) nextBtn.disabled = (currentPage >= totalPages);
}
// 加载下一页
function loadNextPage() {
if (currentPage < totalPages) {
currentPage++;
const result = loadMemosData(currentPage);
updateHTMl(result.data);
updatePaginationUI();
}
}
// 加载上一页
function loadPreviousPage() {
if (currentPage > 1) {
currentPage--;
const result = loadMemosData(currentPage);
updateHTMl(result.data);
updatePaginationUI();
}
}
// 跳转到指定页
function jumpToPage() {
const input = document.getElementById('target-page');
const targetPage = parseInt(input.value);
if (targetPage >= 1 && targetPage <= totalPages) {
currentPage = targetPage;
const result = loadMemosData(currentPage);
updateHTMl(result.data);
updatePaginationUI();
input.value = '';
}
}4.3 修复的 Bug
Bug 1:currentPage 重复声明
- 问题:变量在两处声明(line 1060 和 1703)
- 解决:移除第二处的
var关键字,只保留赋值
Bug 2:insertMemo is not defined
- 问题:分页函数调用了不存在的
insertMemo()函数 - 解决:改用
updateHTMl(result.data)更新页面
Bug 3:跳转输入框 ID 不匹配
- 问题:HTML 中 ID 为
target-page,JS 中查找page-input - 解决:统一使用
target-page
Bug 4:首页总页数显示为 1
- 问题:首次加载时未调用
updatePaginationUI() - 解决:在
getFirstList()中数据加载完成后调用
5. 添加全局搜索功能
目标:支持搜索所有卡片的内容和标签,而非仅当前页
5.1 UI 实现
在 白日梦与诗的碎碎念/index.html 中添加搜索按钮和搜索框:
<div class="button-container">
<a href="/blog" class="blog-btn">博客</a>
<button id="search-btn" class="search-btn">🔍 搜索</button>
</div>
<div id="search-container" class="search-container" style="display: none;">
<input type="text" id="search-input" placeholder="搜索卡片内容或标签..." />
<button id="do-search-btn">搜索</button>
<button id="clear-search-btn">清除</button>
</div>5.2 搜索逻辑
在 白日梦与诗的碎碎念/assets/js/main.js 中实现搜索功能:
// 切换搜索框显示
function toggleSearch() {
const searchContainer = document.getElementById('search-container');
if (searchContainer.style.display === 'none') {
searchContainer.style.display = 'flex';
document.getElementById('search-input').focus();
} else {
searchContainer.style.display = 'none';
}
}
// 执行搜索
function performSearch() {
const keyword = document.getElementById('search-input').value.trim();
if (!keyword) return;
// 搜索所有卡片(不仅是当前页)
const results = allFlatMemos.filter(memo => {
const contentMatch = memo.content.toLowerCase().includes(keyword.toLowerCase());
const tagMatch = memo.tags && memo.tags.some(tag =>
tag.toLowerCase().includes(keyword.toLowerCase())
);
return contentMatch || tagMatch;
});
// 显示搜索结果
updateHTMl(results);
console.log(`搜索 "${keyword}" 找到 ${results.length} 条结果`);
}
// 清除搜索
function clearSearch() {
document.getElementById('search-input').value = '';
currentPage = 1;
const result = loadMemosData(1);
updateHTMl(result.data);
updatePaginationUI();
}特点:
- 搜索范围:所有卡片(
allFlatMemos),不限于当前页 - 搜索字段:卡片内容 + 标签
- 大小写不敏感
- 支持清除搜索返回首页
6. 相关文件修改
| 文件 | 修改内容 |
|---|---|
白日梦与诗的碎碎念/index.html | 添加字体CDN、搜索UI、优化分页布局 |
白日梦与诗的碎碎念/assets/js/main.js | 修复标签正则、添加分页函数、添加搜索函数、前端过滤滴答清单 |
.obsidian/plugins/cflow-sync/main.js | 插件层清理滴答清单引用 |
scripts/sync-memos.sh | 添加Python清理脚本 |
content/4 Archives/往年输出/cflow-memos.json | 清理后的JSON数据 |
7. 部署信息
提交哈希:867502d5
提交信息:
feat: 完整优化碎碎念功能
- 添加霞鹜文楷字体支持
- 修复标签识别正则表达式(支持行尾和标点前的标签)
- 移除滴答清单引用块(插件+脚本+前端三重过滤)
- 实现完整分页功能(上一页/下一页/跳转/总页数显示)
- 添加全局搜索功能(搜索所有卡片内容和标签)
- 优化分页布局为单行显示
- 修复多个bug(currentPage重复声明、跳转ID不匹配、首页总页数显示等)
部署时间:2025-12-03