Why

其实 Quartz 原生就集成了 GitHub 的 Giscus 评论系统,之前也已经配置成功了。但我总觉得对于一个平时也没多少人看的“数字花园”来说,Giscus 的门槛还是太高了。 读者偶尔路过,想顺手留个言,结果还得先登录 GitHub、授权、注册,这一套流程走下来,读者搞不好早就被劝退了。

我想给这个博客加个对访客更友好的评论区,就是那种想说就说、不需要非得登录的系统。之前尝试 Astro 的时候发现不少主题 (其中这个我觉得retypeset特别好看,非常让人有阅读的欲望) 都在用 Waline,感觉比较轻量,就决定给我的 Quartz 也整一个。

还有一个原则:这个数字花园既然是我的知识库,那它就得保证随时能看。虽然我在家里也折腾了一套跑着 Proxmox 的服务器和 Home Lab,但我不想把博客挂在家里。 99.9%的时候服务器下线都是我在瞎折腾,这个时候如果笔记访问不到了,无异于双重打击。 所以这次我打算跟这个 Quartz 的托管方式一样,评论系统也要“白嫖”免费的托管方案(这点实际上跟之前用 Giscus 的原因是一样的)。

虽然 Waline 在 Quartz 上没法像 Giscus 那样“一键即用”,得自己跑一遍、踩一遍坑,但好在现在有 AI 帮手,连滚带爬也算跑通了。

Overview

在正式开始之前,先来看看这套方案总共需要动哪些地方。:

  • Database — Supabase

    • 选它是因为 Supabase 的 Free Plan 给 500MB 的存储空间,对于这种小体量的博客评论来说应该也许大概是够了? 而且用起来之后它那个 Web UI 做得还挺好,我可以直接在网页上看到数据库里的一条条数据,数据少时,管理起来非常直观。
  • Hosting — Vercel

    • Waline 本质上是一个 Web 应用。我们需要把这个应用托管在 Vercel 上。Vercel 托管这种小型的评论系统也是完全免费的,速度快,而且部署起来非常省心。
  • Quartz/Cloudflare Pages Integration

    • 我的博客前端是托管在 Cloudflare Pages 上的。我们需要把 Vercel 上跑的那个 Waline,跟 Cloudflare 上的 Quartz 给集成起来。 为了方便访问,我还在 Cloudflare 上做一层中转。

Supabase

直接去 Supabase 官网开个项目,选那个 Free Plan。虽然只有 500MB 空间,但存评论文字应该大概也许是够用了。

创建 Project 的时候会生成 Database 密码,这个密码是需要记住的。之后 dial to this DB 需要用到这个东西。

1. 建表

Supabase 默认应该是创建一个 PostgreSQL 数据库,所以我们需要按照 Waline 的这个文档 给 Waline 建表。

建表就是把这个里面的 sql 在新 db 里面跑一遍。Copy 这个 到 SQL Editor 里面执行就行。

create-tables

NOTE

说实话我没想到建表这一步没有自动化完成的,但是想想也OK。只是很容易跑流程的时候错过。

主要就是建立三张新表,看到 wl_users, wl_comment, wl_counter 三个表建好就可以了。

2. 连接方式

必须选 Session Pooler

  • 现状:Supabase Free Plan 的直连(Direct Connection)现在不给 IPv6 地址了。
  • 问题:咱们要把应用托管在 Vercel 上,而 Vercel 的免费环境好像(待确认)只认 IPv4。
  • 对策:在拿 Connect 参数的时候,选择 Session Pooler

session pooler

用了这个模式,Supabase 会给你合成一个新的 Host 地址,端口现在还是 5432(以前好像是会变成 6543)。

Example:

  • postgresql://postgres.xxxxxxyyyyyyzzzzzz:[YOUR-PASSWORD]@aws-0-us-west-2.pooler.supabase.com:5432/postgres
  • 这个地址是支持 IPv4 的,把这个参数记下来,一会儿去 Vercel 那边填。

如果是direct connect 的话, URL 是这样的:

  • postgresql://postgres:[YOUR-PASSWORD]@db.xxxxxxyyyyyyzzzzzz.supabase.co:5432/postgres
  • 这个就不能用

Vercel

Waline 的官方文档提供了 Vercel 上面的一键部署:https://waline.js.org/en/guide/get-started/

one-click-deploy

但是后续的部分官方文档此时此刻用的是 Neon 的 serverless postgreSQL。我之前已经创建了 Supabase 上面的 DB,所以我们创建好 project 之后, 就不能完全按照这个文档走了。(Neon 的免费 plan 好像也是 500MB,所以说都差不多。)

这一步 ‘Project Name’ 如果没有重名的,似乎就会变成 App URL 的一部分:

  • https://<project_name>.vercel.app/

然后针对 Supabase 我们需要配置如下 Environment Variables (在 Project Settings 下面)

  • Example: postgresql://postgres.xxxxxxyyyyyyzzzzzz:[YOUR-PASSWORD]@aws-0-us-west-2.pooler.supabase.com:5432/postgres
    • PG_HOST:就是 Connection Pooler 给你的那个带 pooler.supabase.com 后缀的长地址。aws-0-us-west-2.pooler.supabase.com
    • PG_USER:通常是 postgres.[你的项目ID]。postgres.xxxxxxyyyyyyzzzzzz
    • PG_PASSWORD:你创建项目时自己设的那个数据库密码。
    • PG_DB:默认一般是 postgres。
    • PG_PORT:之前是填 6543,现在好像变成了 5432。

然后还需要配置一个 JWT_SECRET Environment variable。可以 openssl rand -base64 32 生成。

WARNING

如果不添加 JWT_SECRET 的话,我发现如果我要修改昵称,会出现 Error: 500: invalid input syntax for type integer: "user"

完整 Environment variable 列表:

  • PG_HOSTaws-0-us-west-2.pooler.supabase.com
  • PG_USERpostgres.xxxxxxyyyyyyzzzzzz
  • PG_PASSWORD:your DB password
  • PG_DBpostgres
  • PG_PORT5432
  • JWT_SECRET: $(openssl rand -base64 32)

怎么才算“部署成功”?

部署完成后,Vercel 会给你一个二级域名(比如 xxxxxxyyyyyyzzzzzz.vercel.app)。

你直接访问这个域名,首先要确保这个域名是可以访问的打开之后,能看到一个评论框

comment-page

然后后面加上 /ui/register,注册成为第一个用户,也就是管理员用户。

register

如果你能顺利看到注册页面,并且注册的第一个账号自动变成了管理员(可以在 Supabase 里面看到新建的用户),那就说明 Vercel 已经成功勾搭上了 Supabase。

最后测试一下可以写评论,就算齐活了。

Cloudflare

因为 Vercel 的二级域名在国内访问不稳定,我们必须通过 Cloudflare Pages 做一层中转(Proxy)。这不仅仅是为了访问速度,更是为了让你的评论系统能跟博客域名统一。

为什么不能直接用 Vercel 的地址? 如果直接在 Quartz 里填 Vercel 的地址,除了国内访问可能被墙,还会遇到跨域(CORS)报错。所以我们在 Cloudflare 上建了一个 functions/waline/[[path]].js 脚本,让它充当“中转站”。

NOTE

其实这里用中转站并不一定真的能起到效果,不过先这么做做试试。

Quartz Code

整个 Quartz 代码库里,为了支持这个评论系统,你一共只需要碰这三个文件:

  • quartz/components/Waline.tsx (新建的组件本体)
  • quartz/components/index.ts (加一行注册代码)
  • quartz.layout.ts (在 afterBody 里把它挂上去)

quartz/components/waline.tsx

Add following code

import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
 
export default (() => {
  function Waline(props: QuartzComponentProps) {
    return (
      <div id="waline-container">
        {/* 引入 Waline 官方 CSS */}
        <link rel="stylesheet" href="https://unpkg.com/@waline/client@v3/dist/waline.css" />
 
        {/* 评论框挂载点 */}
        <div id="waline"></div>
 
        {/* 核心驱动脚本 */}
        <script type="module" dangerouslySetInnerHTML={{ __html: `
          import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js';
 
          function loadWaline() {
            const container = document.getElementById('waline');
            if (!container) return;
 
            init({
              el: '#waline',
 
              // 🚨 修改点 1:使用绝对路径,解决 POST 请求 Failed to fetch 的问题
              serverURL: window.location.origin + '/waline',
 
              // 自动适配 Quartz 的深浅色模式
              dark: 'html[saved-theme="dark"]',
 
              // 开启表情包
              emoji: [
                'https://unpkg.com/@waline/emojis@1.2.0/twemoji',
                'https://unpkg.com/@waline/emojis@1.2.0/bilibili',
                'https://unpkg.com/@waline/emojis@1.2.0/weibo',
                'https://unpkg.com/@waline/emojis@1.2.0/alus',
              ],
            });
          }
 
          // 首次加载页面时运行
          loadWaline();
 
          // 当在 Quartz 博客内点击链接跳转时,重新渲染评论框
          document.addEventListener('nav', () => {
             loadWaline();
          });
        `}} />
      </div>
    )
  }
 
  // 组件自带的 CSS
  Waline.css = `
  #waline-container {
    margin-top: 2rem;
    padding-top: 2rem;
  }
  `
 
  return Waline
}) satisfies QuartzComponentConstructor
  • serverURL 需要匹配好:/waline 就是在 Cloudflare pages 部署的时候,下面建的那个文件夹:functions/waline/。前端把请求发给 /waline,Cloudflare 才能接得住,然后转发给后端的 Vercel。
  • 解决刷新消失的问题 (nav 事件):如果你不写最下面那段 document.addEventListener('nav', ...),然后如果 Quartz 开启了 SPA (single page application)评论框就只会在第一次打开博客时出现。 因为 Quartz 换页不重新加载 JS,所以必须手动监听它的 nav 动作,让它每次跳页面都重新渲染一次评论框。
  • dark mode 适配:Quartz 切换深色模式的暗号是 html[saved-theme=“dark”],把这个规则写进去,评论框的黑白皮肤才能跟着自动切换。

quartz/components/index.ts

Add at the end: export { default as Waline } from "./Waline.tsx" to register our new component.

quartz.layout.ts

Under export const defaultContentPageLayout: PageLayout = {

export const defaultContentPageLayout: PageLayout = {
  beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
  left: [
    // ... 左侧边栏组件
  ],
  right: [
    // ... 右侧边栏组件
  ],
  // 🚨 关键改动在这里:把你捏的 Waline 加到 afterBody 里
  afterBody: [
    Component.Waline(),
  ],
}

如果你有一个 indexContentPageLayout,也可以加进去。这个网站就在 index 上面也加进去了。

Cloudflare functions code

You need a [[path]].js file, I put it here: functions/waline/[[path]].js in my Quartz repo

Content of [[path]].js:

export async function onRequest(context) {
  const WALINE_URL = "https://xxxxxxyyyyyyzzzzzz.vercel.app";
  const url = new URL(context.request.url);
 
  // 逻辑:前端请求 /waline/api/comment
  // 代理剥离 /waline,变成 /api/comment 转发给 Vercel
  const targetPath = url.pathname.replace(/^\/waline/, '');
  const targetUrl = new URL(targetPath + url.search, WALINE_URL);
 
  const headers = new Headers(context.request.headers);
  headers.delete("Origin");
  headers.delete("Referer");
 
  try {
    return await fetch(targetUrl, {
      method: context.request.method,
      headers: headers,
      body: ["GET", "HEAD"].includes(context.request.method) ? null : context.request.body,
    });
  } catch (err) {
    return new Response(JSON.stringify({ error: "Proxy Error" }), { status: 500 });
  }
}

关于 functions 文件夹的位置

Cloudflare 硬性规定:所有的后端 Functions 代码,必须放在你部署根目录的 functions 文件夹下面。

WARNING

不能把 functions 放在 quartz/public 文件夹里 (这个目录是Quartz在Cloudflare上面部署的时候默认的静态文件输出目录). 而 functions 必须留在根目录,它是给 Cloudflare 服务器跑的逻辑。 所以,如果你的 Quartz 的 repository 并不是直接checkout到你这个 cloudflare worker 或者 pages 的root dir的话,那你需要手动的在 Build command 里面把 functions/ copy出来。

这段代码到底是干嘛的?因为国内直接连 Vercel 很容易连不上,还会遇到各种跨域报错,我们就让访客的浏览器先把评论发给 Cloudflare(也就是咱们在 Waline.tsx 里配置的那个 /waline 路径)。 Cloudflare 的边缘节点拿到请求后,跑一下这段代码,把外层的 /waline 外衣扒掉,顺手抹掉 Origin ,然后把干净的请求转发给真正的 Vercel 后台。这样整个流程就走通了。

后记

这么一通做完之后就是你们现在能看到的这个网站的评论系统了。应该是可以用的。而且应该是不需要注册就可以留言的。

Waline 好像提供了很多个配置选项,然后以后有机会有时间再写一篇文章讲一讲这里面都有些什么配置,能产生什么样的效果,做做实验玩一玩。 如果有时间的话,再搞一搞吧。

Appendix

Full psql code for creating tables

CREATE SEQUENCE wl_comment_seq;
 
CREATE TABLE wl_comment (
  id int check (id > 0) NOT NULL DEFAULT NEXTVAL ('wl_comment_seq'),
  user_id int DEFAULT NULL,
  comment text,
  insertedAt timestamp(0) without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
  ip varchar(100) DEFAULT '',
  link varchar(255) DEFAULT NULL,
  mail varchar(255) DEFAULT NULL,
  nick varchar(255) DEFAULT NULL,
  pid int DEFAULT NULL,
  rid int DEFAULT NULL,
  sticky numeric DEFAULT NULL,
  status varchar(50) NOT NULL DEFAULT '',
  "like" int DEFAULT NULL,
  ua text,
  url varchar(255) DEFAULT NULL,
  createdAt timestamp(0) without time zone NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt timestamp(0) without time zone NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
) ;
 
 
CREATE SEQUENCE wl_counter_seq;
 
CREATE TABLE wl_counter (
  id int check (id > 0) NOT NULL DEFAULT NEXTVAL ('wl_counter_seq'),
  time int DEFAULT NULL,
  reaction0 int DEFAULT NULL,
  reaction1 int DEFAULT NULL,
  reaction2 int DEFAULT NULL,
  reaction3 int DEFAULT NULL,
  reaction4 int DEFAULT NULL,
  reaction5 int DEFAULT NULL,
  reaction6 int DEFAULT NULL,
  reaction7 int DEFAULT NULL,
  reaction8 int DEFAULT NULL,
  url varchar(255) NOT NULL DEFAULT '',
  createdAt timestamp(0) without time zone NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt timestamp(0) without time zone NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
) ;
 
 
CREATE SEQUENCE wl_users_seq;
 
CREATE TABLE wl_users (
  id int check (id > 0) NOT NULL DEFAULT NEXTVAL ('wl_users_seq'),
  display_name varchar(255) NOT NULL DEFAULT '',
  email varchar(255) NOT NULL DEFAULT '',
  password varchar(255) NOT NULL DEFAULT '',
  type varchar(50) NOT NULL DEFAULT '',
  label varchar(255) DEFAULT NULL,
  url varchar(255) DEFAULT NULL,
  avatar varchar(255) DEFAULT NULL,
  github varchar(255) DEFAULT NULL,
  twitter varchar(255) DEFAULT NULL,
  facebook varchar(255) DEFAULT NULL,
  google varchar(255) DEFAULT NULL,
  weibo varchar(255) DEFAULT NULL,
  qq varchar(255) DEFAULT NULL,
  oidc varchar(255) DEFAULT NULL,
  "2fa" varchar(32) DEFAULT NULL,
  createdAt timestamp(0) without time zone NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt timestamp(0) without time zone NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
) ;