项目概述

将 cflow(基于 memos 魔改的碎片笔记工具)的公开卡片功能嵌入到 Quartz 博客中,实现:

  1. 碎碎念列表页 /c/ - 展示所有公开卡片
  2. 卡片详情页 /c/3251 - 点击卡片编号可查看单卡片详情
  3. 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
EOF

3. 单卡片详情页功能

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 --serve

Git 操作

# 查看状态
git status
 
# 回退到指定版本(调试时使用)
git checkout 3bbdfe09a3c57b665635a36c86d446f796d6c640
 
# 返回最新版本
git checkout main
 
# 提交修改
git add quartz/components/scripts/spa.inline.ts
git commit -m "fix: 让 Quartz SPA 路由忽略 /c/* 路径"
git push

package.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。

排查过程

  1. 检查发现 _redirects 文件在 Cloudflare 上返回 404,说明文件没有被正确部署
  2. 发现 Quartz 构建时会先清空整个 public/ 目录(quartz/build.ts 第 70 行:await rm(output, { recursive: true, force: true })
  3. 尝试将 _redirects 创建移到 sync-memos.sh 脚本末尾,但仍然不生效
  4. 怀疑是 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 重写)

相关提交

提交说明
3af399b4feat: 添加 Cloudflare Pages Function 处理 /c/:id 路由(最终解决方案)
16eff391feat: 添加碎碎念单卡片详情页路由支持
c96dec6efix: 让 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 Functionsfunctions/c/[id].js),这是最可靠的方式,不依赖文件系统和构建脚本。

经验教训

  1. 本地开发环境和生产环境(Cloudflare Pages)的行为可能不同
  2. _redirects 文件虽然简单,但在复杂构建流程中可能不可靠
  3. Cloudflare Pages Functions 是处理动态路由的最佳方案
  4. 调试时要检查文件是否真正部署到了生产环境(通过直接访问 /_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.jssyncToBlogJson() 方法:

// 清理滴答清单引用块并重新提取标签
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

访问地址https://blog.brmys.cn/c/