个人网站的再次重建

技术

自从我 22 年中迁移博客 后,事情发展算是比较符合预期,过往旺盛的表达欲也有所集中,从知识管理、人生体系、个人的方向和规划等视角,随着文章的梳理,也经历了一轮重塑。

但在技术层面,我对于这套方案仍有些不满,只是因为工作生活的精力分配原因一直没有投入时间去改。一方面是 Notion 的表达力的有限,以及生成的站点 CSS 样式的结构略复杂,不好定制;另一方面是其 RSS 订阅的逻辑会消耗大量的 Vercel 的云函数运行时长,频频超限;比较不能忍的是有的 Page 莫名其妙无法访问,但我不知道背后发生了什么,只能复制粘贴重新搞,这也让我对以 Notion 作为博客后端 CMS 的方案信心大减。

这件事情的推进,伴随着许多巧合和水到渠成:某天偶然意外拿到了 zgq.me 域名;设计师好友 Yvon 在思考自己新的职业规划,尝试做了自己的 作品集站点;去年和 @johnbanq 说起要搞个人网站,最近终于上线;外加去年在写 0xFFFF 计算机入门专题 的时候也比较重度地使用了 Markdown 文件和静态网站生成器,重建个人网站的的动机愈发旺盛。在酝酿了好多个周末以后,终于是落地了一个版本。

由此,也在这里简单梳理一下重建个人网站这事儿的考虑和选择,以供参考。

目标

大的目标还是聚焦,在 个人知识管理体系 中,将所有对外的输出,归集到 zgq.me 域名下,比如较长的文字、个人状态、项目介绍等等,引用的源头都来自于此(Single source of truth 原则)。与此同时,将内容页面都统一在一套代码仓库,解除第三方依赖,确保数据的完整与安全。

在技术栈的角度,我想把个人的关注点集中在 Web App 开发,积累尽可能聚焦在这一领域。所以这里也继续用已有的前端技术去实现,继承 Nobelium 的衣钵,把内容来源换成 Markdown 文件。

数据导出

这里好像没什么可复用的经验,主要是把 Notion 中存放的文章一股脑导出,然后再针对性地做些处理。

Notion 导出 Database,它会将数据打包到一个 zip 文件中,其中有一个 csv 存 Database 的各个字段,以及一系列 Markdown 的 md 文件,以及 md 文件对应引用的图片资源等。

这里一个核心是,以文章的 slug 作为文章的 key,然后以此建立一个目录,存放其引用的各项资源(主要是图片)。md 文件内容参考 Hexo / Jekyll 中名为 Front Matter 的做法,头部将元数据(发布时间、标签等)以 yaml 的格式输出至 md 文件的头部,紧接着才是文章正文。

目录结构上,以年为单位划分(毕竟一年产出也没有几篇),整理出来大概类似以下的结构:

内容目录的结构内容目录的结构

核心

最开始考虑 Hexo,其生态有些复杂,懒人有点不想琢磨,遂先战术放弃。实质上我只是想要一个方便的 Markdown 转 HTML 能力,以及生成网站其他必要部分的框架,并不想在别的方面纠结太多,所以先用成熟的方案跑起来即可。所以这里网站构建上还是会继续用 Next.js,它的 SSG 方案基本可以满足我这里的需求。当然也保留使用其他方案(比如 Waku)的空间,目前看 Next.js 耦合太多 Vercel Only 的东西,并不必要。

然后需要关注的是 Markdown -> HTML DOM 的解析方案,这里就直接用 @mdx-js/mdx 梭哈,综合下来还涉及到一些扩展组件,如 Front Matter、数学公式、表格等 Markdown 扩展语法的支持,基本上抄抄默认配置就可以。

接下来是网站的 CSS 样式方案,原则上基础 CSS 样式需要尽可能薄。这里直接用 Tailwind CSS 来做,日常用到的 CSS 样式基本都可以被它 cover 住,并且它背后是一套完整的 PostCSS 工具链支撑,基于它做样式方案代码可以更加简洁,尽可能减少全局的重复样式定义。基础文章排版则直接引用 @tailwindcss/typography 的样式。

RSS Feed 与 sitemap 的方案参考 Nobelium 的,用 feednext-sitemap 整一个,也挺简单方便的。

图片处理

图片是直接从博文 Markdown 中引用的,但由于 Next.js 的设定,无法直接访问到 public 目录外的静态资源,显然不是太方便。查了一堆资料,暂时没找到更好的解法,只能单独 copy 到 static 目录,然后用 _next/static 来访问到对应的文件。

config.plugins.push(
  new CopyPlugin({
    patterns: [{
      from: './content/',
      to: './static/assets/',
      filter: async (resourcePath) => {
        if (resourcePath.includes('.md')) {
          return false;
        }
        return true;
      }
    }],
  })
);

另一个问题是在我把网站部署 Vercel 之后,发现每次打开页面都会请求一次图片资源,虽然是 304 Not Modified,但其实也很影响体验。检查请求发现 cache 的 max-age 竟然是 0...

找了很久没有找到 Next.js 和 Vercel 可以怎么调整文件缓存,我还是在 CDN 上配置好了,在 Next 这边做一组 URL 替换,以及禁用默认在 Vercel 烧钱的图片自动压缩服务。另外尽可能在后端读到各个图片的 size,避免页面初始化加载过程发生 Layout Shift(类似我之前在 Flarum 上的 做法)。

整体处理代码如下:

const components: ReturnType<UseMdxComponents> = {
  img: (props) => {
    const { src, alt } = props;
    const realSrc = `${CDN_URL || ''}/_next/static/assets/${pathBase}/${src}`;

    // next/image support
    const { width, height } = (src && imageSizeMap?.[src]) || {};
    const useNextImage = width && height;
    const imgWithAltElement = useNextImage ? (
      <Image width={width} height={height} className="mx-auto mb-4 max-h-[300px] rounded-l object-contain" src={realSrc} alt={alt || ''} />
    ) : (
      <img className="mx-auto mb-4 max-h-[300px] rounded-l" src={realSrc} alt={alt} />
    );
    const imgWithOutAltElement = useNextImage ? (
      <Image width={width} height={height} className="mx-auto max-h-[300px] rounded-l object-contain" src={realSrc} alt="" />
    ) : (
      <img className="mx-auto max-h-[300px] rounded-l" src={realSrc} alt="" />
    );

    if (alt) {
      return (
        <span className="block">
          {imgWithAltElement}
          <span className="block mb-4 text-center text-xs text-gray-400 px-4">{alt}</span>
        </span>
      );
    }
    return imgWithOutAltElement;
  },
};

评论区

不想再用 cusdis,感觉逻辑有些复杂,并且 Randy Lu 没有很积极的维护它。所以我先把原有的评论随着数据导出一起存了一份,准备写一个新的,尽可能极简,减小维护的负担。

一个评论框业务逻辑本身并不复杂,主要是提交评论的组件,以及后端存储的数据库。

嵌入文章的 UI 评论组件,先糊一个 React 组件自用,后续看情况再扩展到 iframe 嵌入的 widgets。

数据库的考虑,个人博客站点的评论量其实很小,SQLite 已足够支撑。核心在于针对某个页面维护一个 comment 列表,以及 comment 之间可能的父子关系。所以用一个 comments 表,补上核心数据以及一些必要的字段,就足够。

然后需要一个针对博主的简单 CRUD 管理后台,实现评论回复,删除 / 审核,并根据用户回复的动态,实现 Email 通知提醒的能力。

准备是用 Koa + SQLite + Vite React 先做一个,写好再整理分享出来。整体结构类似 weibo-rss;因为只有我一个用户,所以后台的登陆权限校验相关,用 JWT 认证就足够,hash 直接写死;Email 发送直接走 SES。主打一个「老夫写代码就是一把梭」。

Reference

新网站的建设,参考了不少同路人的实现,特此鸣谢:

  1. 博客的内容结构,参考了 Limboy 的 我的博客系统演变之路,还有 板桥工学Sukka's Blog
  2. 关于 content 目录的组织,友情链接的设定,参考了二花的 This Cute World