站上的笔记是构建期编译的 MDX,作者只有我一个人。但读者偶尔会想"把正在看的那篇资料也放进这个阅读环境里"——于是有了 /import。
它没有后端。文件不出浏览器,存在 IndexedDB 的 exoself 库里,docs 表,主键是文档 id。
为什么不做上传
一个上传功能背后是四件事:账号体系、对象存储、内容审核、隐私承诺。个人站没有一项预算是为它们准备的。而"不做"带来的限制很干净——换一台设备就看不到,这句话可以直接写在界面上,不需要解释。
id 由内容算出来
export function docIdFor(fileName: string, body: string): string {
let hash = 2166136261
const seed = `${fileName}:${body.length}:${body.slice(0, 400)}`
for (let i = 0; i < seed.length; i += 1) {
hash ^= seed.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return `local-${(hash >>> 0).toString(36)}`
}FNV-1a,种子是文件名 + 长度 + 前 400 字。这样重复拖入同一个文件会命中同一条记录,而不是在列表里堆出两份。代价是只改第 400 字之后的内容会被当成新文档——对一个本地笔记本来说,这个取舍可以接受。
两条渲染路径
| 站内笔记 | 导入文档 | |
|---|---|---|
| 编译时机 | 构建期(MDX) | 打开时(运行时) |
| 目录 / 时长 | 构建期算好注入 | 打开时现算 |
| 代码高亮 | shiki,双主题 | 纯 <pre>,无高亮 |
| 体积 | 索引与正文分开加载 | 独立分包,只被 /import /local 引用 |
src/lib/markdown.ts 是手写的,没装 marked 或 markdown-it。两个理由:
- 不给首屏加体积。这个模块只在两个路由里被引用,Vite 会把它切成独立 chunk;装一个通用渲染器反而会把它推到公共依赖里。
- 安全边界更清楚。整个流程是"先转义、再套我自己生成的标签",原文里的 HTML 一律变成文字。这比"引一个渲染器再加一个 sanitizer"更难漏,代价是不支持内嵌 HTML。
支持范围够用:标题、段落、围栏代码、引用、嵌套列表、GFM 表格、分隔线、行内代码、粗体斜体删除线、链接与图片。
没有 frontmatter 也要能读
导入的文件多半没有 frontmatter。缺字段时全部走默认值,而不是拒收:
title:frontmatter 的 title → 正文第一个#→ 文件名;date:今天;updated:今天;topic:misc(白名单之外的值也归到这里,不能让一个手写的topic: 随便把筛选器打穿)。
摘要的来源分两种,用 summaryFrom 标出来:'meta' 表示来自文件头,'body' 表示截自首段。阅读页靠这个字段决定要不要显示摘要——首段在正文里本来就会出现一次,页头再放一遍就是同一句话读两次。
不支持的时候要明说
不是所有环境都有 IndexedDB(隐私模式、部分内嵌浏览器)。putLocalDoc 会抛 这个浏览器不支持本地存储,listLocalDocs 返回空数组让列表照常渲染——不能静默失败,否则用户会以为文件导入成功了。
留言
还没有人说话读到这里,说两句。不用登录,昵称随便填。