🌞Moon Will Know
📑

kindle-highlight 开发中遇到的问题

写在前面

连续写了很多篇出去玩的博客,很久都没写关于开发的文章了。最近在空闲时间放弃了无聊的兴趣,拾起了吃灰很久的 Kindle,一拿一放之间,亚马逊已经终止了 Kindle 在大陆的服务。一开始是看了《足利女童失踪事件》,因为听播客 痴人之爱 的时候,听到了一期关于丰饶之海的节目,于是饶有兴趣的阅读了《春雪》和《奔马》。沉醉在三岛由纪夫华丽的辞藻中,然后开始尝试在 Kindle 中划线,才发现这个功能是这么的不好用。
于是开发一个基于 Kindle 书摘文件生成一个可读性应用的想法开始在我脑中扎根。但是 Kindle 在不同系统中生成的书摘 txt 文件甚至是采用不同语言格式的(逆天)。所幸 kindle-zhcn-clippings-to-json 对中文 My Clippings.txt 文件进行了解析,抱着把一个 Kindle “粘”到电脑屏幕上的想法,一个仿照 Kindle UI 的项目诞生了。
GitHub: kindle-highlight ( 点个 star 再走)
DEMO: moonlight
notion image
notion image
notion image

遇到的问题

Nextjs 在此之前一直是我较为喜爱的一个框架,因为它事无巨细的包含了很多处理开发中的细枝末节的配置。App route 是它最新推出的模式,在之前的 SSR 和 静态生成的基础上,增强了 React server component 的能力。我虽然将这个版本激进地用到了工作中的新项目里,但涉及的内容最多只是到 fetch 和拆分服务器组件上。所以这个项目我给自己设定的需求也算是增强对它的使用。

在 server component 中读取文件

因为 Kindle 的书摘是一个 My Clippings.txt 文件,需要在服务端对它进行读取转换为 JSON。本来以为和在 node 中类似的使用 :
fs.readFileSync(path.join(__dirname,'../../public/My Clippings.txt'))
但是在执行时,这个文件已经被打包到 .next 中,搜索后才发现这是一个讨论已久的问题。这个问题存在很多种解法:

fetch + headers + protocal

因为 My Clippings.txt 文件在 public 中,使用 fetch 就可以请求到,但是在 server component 中使用 /My Clippings.txt 来发起 fetch 并不会拼接上当前的域名,于是需要通过 headers 函数从请求的 header 中获取域名。
import { headers } from 'next/headers'; const fetchClippings = async () => { const host = headers().get("post"); const protocal = host.includes("localhost") ? "http" : "https"; const clippings = await fetch(`${protocal}://${host}/My Clippings.txt`).then( (res) => res.text() ); // ... }

readFileSync + path.join(process.cwd(), "/public/My Clippings.txt")

如果使用 readFileSync 则需要通过 process.cwd() 获取当前进程的目录,在进行拼接。
import path from 'path'; import fs from 'fs'; const fetchClippings = async () => { const clippings = await fs.readFileSync( path.join(process.cwd(), "/public/My Clippings.txt") ); // ... }

edge runtime ?

如果你的部署 All in Vercel 的话,还会遇到在 edge runtime 下如何读取本地本件的问题
export const runtime = 'edge'; // ... fetch(new URL('../../public/My Clippings.txt', import.meta.url)) // ...
虽然在这里写了这么多,最后我选择了在 next.config.js 将 My Clippings.txt 的内容转为一个环境变量来使用,这样在运行时就不会产生什么读取行为。

动态生成 og image

因为采用 Next 和长期使用 Twitte,总是会在这种地方有所追求。而且作为一个书摘项目,除了给自己看,分享给别人,共同谈论读书的乐趣也能给自己的阅读带来正向的提升。所以一张精美的小卡片必不可少!

route handler

Route handler 是 Next 13 新推出的特性,它取代了以往的 /api 目录(其实我觉得没差)。一开始我的目标是使用一个 /og 的 Route handler 来动态返回一个 image 数据。而在页面中导出的 metadata :
export const generateMetadata = async () => { // ... return { twitter: { card: "summary_large_image", title: "...", description:"...", images: "/og" }, openGraph: { title: "...", description: "...", images: "/og" }, }; };

opengraph-image

opengraph-imagetwitter-image 是 Next13 新增的对 metadata 的支持,在对应 route 的目录下新建 opengraph-image.tsx 文件,即可接受路由参数并返回一个 image 文件,同时会自动在 metadata 中生成相应的链接。这里生成图片可以采用:
  • next/server 中提供的 ImageResponse
  • node-canvas@napi-rs/canvas
// /books/[id]/opengraph-image.tsx export const Image = ({ params: {id} }: { params: { id: string } }) = { // 生成并返回图片返回体 }
首页 og image
首页 og image
书摘详情 og image
书摘详情 og image

打包的 og image URL 变成了 http://localhost:3000

如果到了这里你使用 Vercel 部署则万事大吉,如果使用自己的服务器或第三方部署服务。那么你的 og:image 链接很有可能被打包为绝对的http://localhost:3000/... ,要解决这个问题则需要配置一个 metadataBase ,但是在现在 build in everywhere 的情况下,固定一个域名可能并不是那么现实。所以可以动态获取当前访问的域名来设置。
// layout.tsx export const generateMetadata = async () => { const host = headers().get("host"); const protocol = host?.includes("localhost") ? "http" : "https"; const metadataBase = new URL(`${protocol}://${host}`); return { metadataBase, // ... } };

generateMetadata + generateStaticParams = 500

本来使用 My Clippings.txt 文件生成的网站,每次更新这个文件时会伴随一次重新部署,所以对书摘的详情页,采用了 generateStaticParams 静态生成。但在最外层的generateMetadata 中使用了 headers 函数,导致静态强制变成了动态,页面则直接 500,所以这里就面临一个三选一的问题:
  • run in vercel
  • 放弃生成小卡片
  • 放弃静态生成
我最后还是选择放弃静态生成,去掉了 generateStaticParams 之后,一切都变得正常起来了,但是我很讨厌这样的抉择。

无法引入的 .node 包

因为 node-canvas 在 arm64 架构的机器下不兼容,所以采用了 rust 编译的 @napi-rs/canvas ,在使用时遇到了无法识别 .node 文件的报错。在设置中将它设置为外部则可解决。
// next.config.js const nextConfig = { experimental: { serverComponentsExternalPackages: ["@napi-rs/canvas"] } }; module.exports = nextConfig;

RSS 支持

在提供 RSS 支持的时候,我原本以为只能输出为纯文字,遇到了一个文字不能换行的问题,但是看了很多现存的 RSS feed,发现他们都是直接输出 html 结构的,所以直接采用拼接 html 结构来输入。
// ... feed.item({ title: `书摘 -《${title}》- ${author}`, url: `${protocol}://${host}/books/${id}`, date: dayjs(highlights.at(-1)?.time).toDate(), description: highlights .map((highlight) => { const note = highlight?.notes[0]?.text; const noteText = note ? `<p>【笔记:${note}】</p>` : ""; return `<p>${highlight.text}${noteText}</p>`; }) .join("<div>-------------------</div>"), });

写在后面

另外这个应用采用了 zeabur 部署,这是一个和 Vercel 类似的部署平台,对比起自己负担一个服务器费用和管理 Workflow 来说它性价比极高,可以用来部署一些小玩意和 preview。