跳转到内容

基于 Astro 与 Directus 的新时代 JAMStack 博客实践

Updated: at 02:40

最近投入了大量摸鱼时间重构博客。现在大概告一段落了,向大家介绍一下整体的技术选型和具体实现的简要思路。

TLDR:JAMStack 实践,使用最新最潮的前端元框架 Astro,魔改 Astro Paper 主题,搭配 Headless CMS Directus,直通对接思源笔记内容同步,自建 SeaweedFS 分布式文件系统暴露 S3 API 作为图床后端,使用 WebP Server 作为图床前端反代,部署 Cloudflare Pages,实现高度客制化的博客体验。

你可以从这里开始访问我的博客:https://clouder0.com/zh-cn/posts/my-new-blog

Introduction

一路走来,我的博客架构换了一茬又一茬,虽然内容好像更新地也不是很勤快……不,其实还是有持续输出内容的吧!

但是,我现在越来越忙了,也可能是越来越懒了,手动更新博客显然不是什么好的方案,毕竟我目前的内容创作主要集中于:

复制粘贴发送到知乎上,和复制粘贴发送到博客上有什么区别呢,好像也没什么区别,但如果想要支援多平台的话,感觉就很麻烦了。而且我确实很讨厌把差不多的事情重复做很多次。

所以还是来点纯粹的自动化吧!

History

那么讲一讲我博客的历史……曾经用过的方案包括:

Arch

那么,归纳一下我的需求:

当初我听说过有一个框架叫做 Gatsby,不过现在更流行的是 Astro. Astro 虽然可以用来做博客,不过更多时候会拿来做一些企业的 Landing Page. 它的设计架构非常的漂亮,整体上说:

是的,所以理论上在 Astro 里面我们可以做到这样的事情:

而如果你不想静态构建的话,Server Side Rendering 也是改个配置的事情。非常好文明。

Implementation

在实际的搭建过程中,顺序大概是这样的:

Astro

虽然 Astro 挺好的, 主要是它可以非常方便地结合前端技术栈,爱用啥用啥。但还是有一些 Drawbacks,目前我注意到的几点不足是:

Whatever,里面的大部分问题都被我想办法解决掉了。

i18n

具体探索掠过不谈,直接讲目前的 Solution:

使用 paraglidejs,一个 i18n 库,在 yml 中写翻译,然后 compile 成 js 文件,就可以直接在 Astro 中调用了。

但你依然需要为不同的语言生成不同的 static files,要做到这一点的话,可以添加一层路由:

image

然后在 export static path 的时候:

import { availableLanguageTags, languageTag } from "paraglide/runtime";

import * as m from "paraglide/messages.js";
const posts = [
  ...(await getCollection("blog")),
  ...(await getCollection("directus_blog")),
];

const tags = getUniqueTags(posts.filter(p => p.data.lang === languageTag()));

export function getStaticPaths() {
  return availableLanguageTags.map(lang => ({ params: { lang } }));
}

大概是这么个意思,为不同的 language 生成不同的路由。

需要注意:Astro 默认的 Content 必须有 unique slug,而这意味着 [lang]/[slug]​ 这样的路由会导致 zh-cn/mypost​ 和 en/mypost​ 只能指向同一个 mypost.md​,这个时候一个解决方案是:写文件的时候把 slug 开头加上 [lang]-​,然后生成路由的时候删掉。

for (const lang of ["zh-cn", "en"]) {
   if (o.slug.startsWith(`${lang}-`)) {
     return o.slug.slice(lang.length + 1);
   }
 }
return o.slug;

我自己搓了一个简单的切换 language 的小组件。还有各种乱七八糟的细节,这里就不展开了,这个方向是能走通的。

评论区

评论区使用了 Giscus,说实话体验舒适不算好啊草。我尝试 Self Host,但是——

一个小小的评论区就有 2000+ 个依赖包?构建出来的 Docker Image 直接上 G?属实是非常的操蛋啊。

Anyway,还是使用了。放弃了 self host,因为没跑起来,我怀疑是不支持 Bun runtime. 使用了官方构建的版本。

官方的内嵌 script 跟 Astro 搭配比较灵车,建议使用 React 版本。

我自己附加包了一层根据全局的 language 切换 Giscus 语言的功能,当然还有主题切换的时候需要跟着切换,这也是一个样式小细节。用 useEffect 注册一个 Event Listener,在 theme switch 的时候 emit event 就行了。

这里曾经遇到了一些灵车的问题,后来发现是 Cloudflare Rocket Loader 会导致 Astro 在 view transition 时 js 脚本执行异常,关了 Rocket Loader 就行。

Content Layer & Remote Markdown

现在推荐的拉取远端资源的方法是使用 Content Layer,不仅可以定义 schema、接入 Astro 的 Content 生态,而且有很方便的内置方法实现 Incremental Loading,不过渲染似乎没法省略掉。

拉取的 Markdown 内容要接入 Astro 中显示,还得自己处理渲染成 HTML. Astro 目前没有给出公开的、能够使用框架配置的 Markdown Render 方法,官方文档中建议使用第三方的 Markdown 渲染。。。但我在 Discord 上学到了这种做法:

import { createMarkdownProcessor } from "@astrojs/markdown-remark";
const preprocessor = await createMarkdownProcessor(ctx.config.markdown);
const rendered = await preprocessor.render(post.content);

Content Loader 这部分的文档还不是很详细,提供一个拉取 Directus 的例子:

export const directusLoader = (conf: {
  url: string;
  username: string;
  password: string;
}): Loader => {
  const client = createDirectus<DirectusSchema>(conf.url)
    .with(rest())
    .with(authentication());
  return {
    name: "directus_loader",
    load: async ctx => {
      // ctx.store.clear();
      // ctx.meta.delete("last-modified");
      await client.login(conf.username, conf.password);
      const last_modified =
        ctx.meta.get("last-modified") ?? new Date(1900, 0, 0).toISOString();
      ctx.logger.info(`Last modified: ${last_modified}`);
      const res = (
        await client.request(
          readItems("BlogPosts", {
            filter: {
              _or: [
                {
                  date_created: {
                    _gt: last_modified,
                  },
                },
                {
                  date_updated: {
                    _gt: last_modified,
                  },
                },
              ],
            },
          })
        )
      ).map(x => ({
        ...x,
        date_created: new Date(x.date_created),
        date_updated: x.date_updated ? new Date(x.date_updated) : null,
      }));
      console.log(res);
      const preprocessor = await createMarkdownProcessor(ctx.config.markdown);
      for (const post of res) {
        const data = await ctx.parseData({ id: post.id, data: post });
        const digest = ctx.generateDigest(data);
        const rendered = await preprocessor.render(post.content);
        ctx.store.set({
          id: post.id,
          data: data,
          body: post.content,
          rendered: {
            html: rendered.code,
            metadata: {
              frontmatter: rendered.metadata.frontmatter,
              headings: rendered.metadata.headings,
              imagePaths: [...rendered.metadata.imagePaths],
            },
          },
          digest: digest,
        });
        ctx.logger.info(`Fetched post: ${post.title}`);
      }
      ctx.meta.set("last-modified", new Date().toISOString());
    },
    schema: directusSchema,
  };
};

然后在 Content Collection 配置中写:

const directus_blog = defineCollection({
  type: "content_layer",
  loader: directusLoader({
    url: DIRECTUS.url,
    username: DIRECTUS.user,
    password: DIRECTUS.password,
  }),
});

Open Graph Image

Astro Paper 内置了相关的功能,但有两个主要的 Drawback:

因此自己搓了一个缓存层,并且切换成了使用本地字体,然后切换成使用 Noto Serif,因为原版字体不支持中文。

Astro 没有给开发者提供 Cache 相关的 API,所以我决定直接写到系统 tmp 文件夹里面去。

这里注意,如果存在 Concurrency,有可能两个相同的 key 同时判定 Cache 不存在,然后进入了生成流程,然后开始写入,此时发生 race condition. 解决方案是在第一个 check cache 后决定需要生成就加上锁,让后续的那个不再生成,而是等待前者生成然后直接读取。

不过实际上没有测试过,自我感觉没问题。Coroutine 还是比 Multithread 好处理很多,但并不意味着我们可以瞎几把写,异步该有的问题还是会有

什么,Astro 默认单 Coroutine 处理?那没事了。

import * as os from "node:os";
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";

const key_locks = new Map<string, Promise<Buffer>>();
export const useCache = () => {
  const path_prefix = `${os.tmpdir()}/blogcache`;
  return {
    read: (key: object, genenrator: () => Promise<Buffer>) => {
      return (async () => {
        const sha256 = crypto.createHash("sha256");
        sha256.update(JSON.stringify(key));
        const shakey = sha256.digest("hex");
        if (key_locks.has(shakey)) {
          const res = await key_locks.get(shakey);
          // console.log("HIT MEM CACHE")
          return res;
        }
        const path = `${path_prefix}/${shakey}`;
        try {
          const res = await fs.readFile(path);
          // console.log("HIT DISK CACHE")
          return res;
        } catch (e) {
          const res = genenrator();
          key_locks.set(shakey, res);
          const awaited_res = await res;
          // console.log("generate success")
          // make dir first
          await fs.mkdir(path_prefix, { recursive: true });
          await fs.writeFile(path, awaited_res);
          // console.log("WRITE DISK CACHE")
          return awaited_res;
        }
      })();回车。
    },
  };
};

KaTeX

KaTeX 接入的话,实际上我们可以做 Server Side Rendering,只需要接入 KaTeX 的 css 就能完美渲染了。用 remark rehype 的插件就行,这部分没什么特殊的,略过了。


Astro 这边暂且主要就做了这些工作,还有一些细小的工作没有写出来。

Directus

选用 Directus 作为我的 Headless CMS. 为什么需要一个 Headless CMS?

可以说这是一层 Abstraction Layer. 如果不使用 Headless CMS 的话,当然我也可以直接在 Astro 中写从信息源拉数据、渲染的功能。但这就非常脏,支持多个信息源的时候也没那么方便,而且拉信息源的构建过程就必须对信息源有访问权限。比如跑 CI/CD 构建的时候,Actor 就对我本地的笔记软件 Backend 没有访问权限,所以不是一个很好的方案。

Directus 在这里扮演的主要是一个简单的数据库的功能。虽然有一个 Admin Panel,理论上我也可以在里面修改内容……但其实思源笔记才是我主要的内容输出端。

当然,以后可能会接入更多的内容输入。

image

读读官方文档就会用了,还是很好上手的。不过嘛,功能本身也不多,只是一个 thin wrapper.

思源笔记发布

Oneway sync,做一个简单的增量更新就可以了。

整体思路是:

当然,实际上我还加入了一些 metadata 相关的属性项,以便在思源笔记这边向 directus 写入属性。不过这里就涉及到一个 two-way sync 的问题,如果在 Directus 上更新内容的话就一定会被思源笔记这边覆写了,除非做一个 directus2siyuan 的写回,有点麻烦,暂且不管。

把核心的 SQL 语句放出来吧,其他的都是 trivial work:

SELECT * FROM 'blocks' b INNER JOIN 
        (SELECT block_id, value AS 'last_update' FROM 'attributes' WHERE name == 'custom-directus-last-update') a  
        ON b.id  == a.block_id AND b.updated > a.last_update AND b.type == 'd'

图床

自建 S3,Minio 有点太重了,我选择使用 SeaweedFS 和 Filer.

暴露出 S3 API 之后,用 WebP Server 包一层,可以做自动转 webp 之类的优化操作,然后再接入 caddy.

思源这边的话,有一个 PicGo 插件可以接入 S3 Backend,上传图片也很方便。

Deploy

目前的 Deploy 主要分四步:

前两步因为需要访问思源笔记的数据,只能在我的本地完成,或者说我倾向于在本地完成。后两步则不一定。

考虑过是否要作 CD,不过想了想还是大道至简,在我的某台 vps 上搭好了环境,然后 alias 一条命令 SSH 上去执行就完事了。

最终日常的博客更新流程就是:

Additional Notes…

强烈推荐使用思源笔记的发布插件,一键发布多平台,虽然感觉有一些方面做得还略有不足,但已经算是可用了。

目前知乎内容均由该插件直接上传。


上一篇
一夜速成密码学
下一篇
4 Empirical Properties of Limit Order Books