hd-garden 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.agents/skills/garden-interactive-chart/SKILL.md +84 -0
  2. package/.agents/skills/garden-interactive-chart/references/shell.html +49 -0
  3. package/.agents/skills/garden-interactive-chart/references/style.md +61 -0
  4. package/.env.example +56 -0
  5. package/.openai/hosting.json +5 -0
  6. package/LICENSE +21 -0
  7. package/Makefile +48 -0
  8. package/README.md +72 -0
  9. package/app/about/page.tsx +60 -0
  10. package/app/admin/page.tsx +117 -0
  11. package/app/api/admin/distribution/route.ts +120 -0
  12. package/app/api/admin/posts/pin/route.ts +32 -0
  13. package/app/api/admin/thoughts/[id]/route.ts +44 -0
  14. package/app/api/admin/thoughts/route.ts +120 -0
  15. package/app/api/admin/uploads/route.ts +20 -0
  16. package/app/api/auth/github/callback/route.ts +136 -0
  17. package/app/api/auth/github/route.ts +29 -0
  18. package/app/api/auth/logout/route.ts +13 -0
  19. package/app/blog/[...path]/page.tsx +135 -0
  20. package/app/blog/page.tsx +139 -0
  21. package/app/chatgpt-auth.ts +86 -0
  22. package/app/columns/[column]/[...path]/page.tsx +160 -0
  23. package/app/columns/page.tsx +68 -0
  24. package/app/components/AdminDashboard.tsx +410 -0
  25. package/app/components/Comments.tsx +55 -0
  26. package/app/components/ContentEmptyState.tsx +51 -0
  27. package/app/components/ExternalEmbed.tsx +127 -0
  28. package/app/components/GardenAdminGuide.tsx +116 -0
  29. package/app/components/GardenContentGuide.tsx +241 -0
  30. package/app/components/GardenMediaGuide.tsx +81 -0
  31. package/app/components/GiscusComments.tsx +71 -0
  32. package/app/components/GithubLiveOverview.tsx +118 -0
  33. package/app/components/GithubProfile.tsx +326 -0
  34. package/app/components/MarkdownArticle.tsx +144 -0
  35. package/app/components/MermaidDiagram.tsx +117 -0
  36. package/app/components/PageIntro.tsx +32 -0
  37. package/app/components/SiteChrome.tsx +18 -0
  38. package/app/components/SiteFooter.tsx +29 -0
  39. package/app/components/SiteHeader.tsx +420 -0
  40. package/app/components/TableOfContents.tsx +433 -0
  41. package/app/components/ThoughtCard.tsx +242 -0
  42. package/app/components/Webmentions.tsx +194 -0
  43. package/app/components/WidgetChart.tsx +222 -0
  44. package/app/garden/page.tsx +284 -0
  45. package/app/garden-modules.d.ts +62 -0
  46. package/app/globals.css +4454 -0
  47. package/app/layout.tsx +102 -0
  48. package/app/lib/admin-auth.ts +134 -0
  49. package/app/lib/content.ts +112 -0
  50. package/app/lib/fallback-site-config.ts +56 -0
  51. package/app/lib/generated-content.ts +380 -0
  52. package/app/lib/github-content.ts +244 -0
  53. package/app/lib/markdown-plugins.ts +13 -0
  54. package/app/lib/oss.ts +100 -0
  55. package/app/lib/public-thoughts.ts +9 -0
  56. package/app/lib/repository-path.mjs +32 -0
  57. package/app/lib/site.ts +51 -0
  58. package/app/page.tsx +119 -0
  59. package/app/robots.ts +15 -0
  60. package/app/rss.xml/route.ts +84 -0
  61. package/app/site.config.ts +35 -0
  62. package/app/sitemap.ts +46 -0
  63. package/app/thoughts/[slug]/page.tsx +79 -0
  64. package/app/thoughts/page.tsx +33 -0
  65. package/app/yaml.d.ts +4 -0
  66. package/bin/garden.mjs +348 -0
  67. package/build/sites-vite-plugin.ts +39 -0
  68. package/eslint.config.mjs +20 -0
  69. package/next.config.ts +13 -0
  70. package/package.json +112 -0
  71. package/postcss.config.mjs +7 -0
  72. package/public/favicon.svg +4 -0
  73. package/public/images/garden/admin-studio.png +0 -0
  74. package/public/og.png +0 -0
  75. package/scripts/build-content.mjs +582 -0
  76. package/scripts/fetch-embed.mjs +189 -0
  77. package/scripts/garden-paths.mjs +21 -0
  78. package/scripts/new-post.mjs +73 -0
  79. package/scripts/prepare-pages-output.mjs +182 -0
  80. package/scripts/send-webmentions.mjs +27 -0
  81. package/site.config.yaml +48 -0
  82. package/templates/site/Makefile +46 -0
  83. package/templates/site/README.md +13 -0
  84. package/templates/site/content/columns.yaml +1 -0
  85. package/templates/site/content/posts/.gitkeep +0 -0
  86. package/templates/site/content/thoughts/.gitkeep +0 -0
  87. package/templates/site/package.json +19 -0
  88. package/templates/site/site.config.yaml +46 -0
  89. package/tsconfig.json +34 -0
  90. package/vite.config.ts +130 -0
  91. package/worker/index.ts +53 -0
@@ -0,0 +1,380 @@
1
+ // Generated by scripts/build-content.mjs. Edit files in content/ instead.
2
+ export type MediaItem = {
3
+ type: "image" | "audio" | "video" | "link";
4
+ src: string;
5
+ alt?: string;
6
+ poster?: string;
7
+ mime?: string;
8
+ title?: string;
9
+ description?: string;
10
+ };
11
+
12
+ export type ContentEntry = {
13
+ title: string;
14
+ slug: string;
15
+ path: string;
16
+ description?: string;
17
+ date: string;
18
+ updated?: string;
19
+ publishAt?: string;
20
+ draft?: boolean;
21
+ topic?: string;
22
+ tags?: string[];
23
+ pinned?: boolean;
24
+ readingTime?: string;
25
+ kind: "post" | "column" | "thought";
26
+ sourcePath: string;
27
+ content: string;
28
+ column?: string;
29
+ columnTitle?: string;
30
+ columnDescription?: string;
31
+ columnStatus?: string;
32
+ columnCover?: string;
33
+ columnCoverAlt?: string;
34
+ order?: number;
35
+ cover?: string;
36
+ coverAlt?: string;
37
+ mediaType?: "image" | "audio" | "video" | "link" | "text";
38
+ media?: string | MediaItem[];
39
+ mediaAlt?: string;
40
+ poster?: string;
41
+ linkTitle?: string;
42
+ linkDescription?: string;
43
+ };
44
+
45
+ export const contentHash = "d29583878502";
46
+ export const posts = [
47
+ {
48
+ "title": "Markdown 复杂语法实验场",
49
+ "slug": "markdown-lab",
50
+ "description": "用一篇真实文章验证 Typora 常用语法、数学公式、Mermaid、脚注、表格与原生 HTML。",
51
+ "date": "2026-07-25T00:00:00.000Z",
52
+ "topic": "写作系统",
53
+ "tags": [
54
+ "Markdown",
55
+ "Typora",
56
+ "Mermaid",
57
+ "KaTeX"
58
+ ],
59
+ "pinned": false,
60
+ "readingTime": "10 分钟",
61
+ "cover": "https://images.unsplash.com/photo-1761414500824-e280de5a1b37?auto=format&fit=crop&w=1600&q=84",
62
+ "coverAlt": "彩色阳台构成的现代建筑立面",
63
+ "path": "markdown-lab",
64
+ "kind": "post",
65
+ "draft": false,
66
+ "sourcePath": "posts/markdown-lab.md",
67
+ "content": "这篇文章不是功能清单,而是一份可以长期保留的**渲染验收样本**。我会在 Typora 中常用的语法和网页输出之间建立一条可重复验证的路径。\n\n> 写作格式的价值,不在于语法多,而在于文件离开编辑器之后仍能完整表达。\n\n## 基础排版与 GFM\n\n实现**掩码(Masking)**操作,引入**多头注意力(Multi-Head Attention)**机制;前**(中文括号)**后也能正常加粗。\n\n<u>新的向量知道前面有 `Your journey`,后面有 `with one step`,下划线应连续。</u>\n\n一段文字可以同时包含**粗体**、*斜体*、~~删除线~~、`inline code` 和 [外部链接](https://typora.io/)。\n\n- 第一层列表\n - 第二层列表\n - 第三层列表\n- 列表之后仍然保持稳定的段落节奏\n\n1. 写作\n2. 本地预览\n3. 发布与验证\n\n- [x] 标题、引用、列表和代码\n- [x] 表格、删除线与任务列表\n- [x] 数学公式与 Mermaid\n- [ ] 用新的真实文章继续回归测试\n\n| 能力 | 写法 | 网页表现 |\n| --- | :---: | ---: |\n| GFM | `remark-gfm` | 已验证 |\n| 数学公式 | `$...$` / `$$...$$` | KaTeX |\n| 图表 | Mermaid fenced code | SVG |\n\n## 语义警告框\n\n> [!NOTE]\n> 普通补充信息会显示为「提示」。\n\n> [!TIP]\n> 推荐做法会显示为「建议」。\n\n> [!WARNING]\n> 需要关注的风险会显示为「告警」。\n\n> [!CAUTION]\n> 可能造成严重后果的操作会显示为「警示」。\n\n## 数学公式\n\n**求和部分:$\\sum_{j=1}^{N}$(上下限)**。\n\n$$\nC_i = \\sum_{j=1}^N w_{ij} \\cdot V_j\n$$\n\n行内公式会和文字一起流动,例如注意力的缩放项 $\\frac{QK^\\top}{\\sqrt{d_k}}$。\n\n块级公式支持多行对齐:\n\n$$\n\\begin{aligned}\n\\operatorname{Attention}(Q,K,V)\n &= \\operatorname{softmax}\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right)V \\\\\nK_{\\text{cache}}^{(t)}\n &= [K_{\\text{cache}}^{(t-1)}; K_t] \\\\\nV_{\\text{cache}}^{(t)}\n &= [V_{\\text{cache}}^{(t-1)}; V_t]\n\\end{aligned}\n$$\n\n矩阵也能正常显示:\n\n$$\nX =\n\\begin{bmatrix}\n1 & 0 & 1 \\\\\n0 & 1 & 0 \\\\\n1 & 1 & 1\n\\end{bmatrix}\n$$\n\n## Mermaid 流程与时序\n\n所有 Mermaid 图表固定使用「中性」主题。每张图都可以在「图表」与「Code」之间切换:前者用于阅读,后者用于检查或复制原始 Mermaid 语法。\n\n下面的流程图描述本地写作到发布的闭环:\n\n```mermaid\nflowchart LR\n A[Typora 写作] --> B{本地验证}\n B -->|通过| C[生成内容索引]\n B -->|失败| A\n C --> D[构建网站]\n D --> E[浏览器验收]\n E --> F[发布]\n```\n\n同一篇文章里可以继续放更复杂的时序图:\n\n```mermaid\nsequenceDiagram\n autonumber\n participant W as Writer\n participant M as Markdown\n participant S as Site\n participant R as Reader\n W->>M: 保存 .md 与媒体\n M->>S: 构建内容与目录\n S-->>W: 返回预览\n W->>S: 发布已验证版本\n R->>S: 阅读文章\n S-->>R: HTML + KaTeX + Mermaid\n```\n\n## 多语言代码高亮\n\n同一种内容结构,在不同语言里应该保留各自可辨识的关键字、类型、字符串、注释与数字层级。\n\n### TypeScript\n\n```typescript\ntype Post<TMeta extends Record<string, unknown>> = {\n slug: string;\n meta: TMeta;\n};\n\nasync function publish<TMeta extends Record<string, unknown>>(\n post: Post<TMeta>,\n): Promise<string> {\n const response = await fetch(`/api/posts/${post.slug}`, {\n method: \"POST\",\n body: JSON.stringify(post.meta),\n });\n return response.ok ? \"published\" : \"failed\";\n}\n```\n\n### Python\n\n```python\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\n@dataclass(frozen=True)\nclass Article:\n \"\"\"选中这行文字时,深浅主题都应保持清晰。\"\"\"\n title: str\n source: Path\n\ndef published_titles(articles: list[Article]) -> list[str]:\n return [article.title for article in articles if article.source.exists()]\n```\n\n### Rust\n\n```rust\n#[derive(Debug)]\nstruct Entry<'a> {\n title: &'a str,\n pinned: bool,\n}\n\nfn pinned_titles(entries: &[Entry<'_>]) -> Vec<&str> {\n entries.iter()\n .filter(|entry| entry.pinned)\n .map(|entry| entry.title)\n .collect()\n}\n```\n\n### Go\n\n```go\nfunc renderAll(ctx context.Context, slugs []string) <-chan Result {\n\tresults := make(chan Result)\n\tgo func() {\n\t\tdefer close(results)\n\t\tfor _, slug := range slugs {\n\t\t\tresults <- render(ctx, slug)\n\t\t}\n\t}()\n\treturn results\n}\n```\n\n### SQL\n\n```sql\nWITH ranked_posts AS (\n SELECT\n title,\n topic,\n ROW_NUMBER() OVER (PARTITION BY topic ORDER BY published_at DESC) AS rank\n FROM posts\n WHERE status = 'published'\n)\nSELECT title, topic\nFROM ranked_posts\nWHERE rank <= 3;\n```\n\n### Swift\n\n```swift\nactor ReadingProgress {\n private var offsets: [URL: Double] = [:]\n\n func save(_ offset: Double, for article: URL) {\n offsets[article] = max(0, offset)\n }\n\n func restore(for article: URL) -> Double {\n offsets[article, default: 0]\n }\n}\n```\n\n## Diff、图片与原生 HTML\n\n```diff\n- const publishing = \"copy and paste\";\n+ const publishing = \"content as source\";\n```\n\n![木桌上的笔记本与钢笔](https://images.unsplash.com/photo-1501525771695-688643efeea4?auto=format&fit=crop&w=1400&q=82)\n\n<details>\n <summary>展开原生 HTML 内容</summary>\n <p>Typora 文件中的 details / summary 会保留为可交互的折叠区域。</p>\n</details>\n\n---\n\n最后用一个脚注说明这篇文章的角色:它既是示例,也是未来升级 Markdown 依赖时的回归测试。[^regression]\n\n[^regression]: 页面构建测试会检查公式与示例内容,浏览器验收会检查 Mermaid 是否被转换为 SVG。"
68
+ },
69
+ {
70
+ "title": "注意力也是一种界面",
71
+ "slug": "attention-is-an-interface",
72
+ "description": "当工具越来越聪明,真正稀缺的不是功能,而是我们把注意力放在哪里。",
73
+ "date": "2026-07-21T00:00:00.000Z",
74
+ "updated": "2026-07-23T00:00:00.000Z",
75
+ "topic": "产品与思考",
76
+ "tags": [
77
+ "产品设计",
78
+ "注意力"
79
+ ],
80
+ "pinned": true,
81
+ "readingTime": "6 分钟",
82
+ "cover": "https://images.unsplash.com/photo-1630234674857-527eb5534837?auto=format&fit=crop&w=1600&q=84",
83
+ "coverAlt": "彩色几何建筑立面",
84
+ "path": "attention-is-an-interface",
85
+ "kind": "post",
86
+ "draft": false,
87
+ "sourcePath": "posts/attention-is-an-interface.md",
88
+ "content": "工具总是在争取更短的路径:少一次点击、少一个页面、少等一秒。但如果只把效率当作终点,我们很容易做出一种**没有摩擦,也没有方向**的界面。\n\n> 好的界面不只是让事情更快发生,也应该帮助人知道什么值得发生。\n\n## 界面在分配什么\n\n每一个高亮、通知和默认选项,都在替用户分配注意力。它们看上去只是视觉决策,实际上却在参与判断。\n\n| 界面动作 | 隐含的决定 |\n| --- | --- |\n| 默认展开 | 这件事更值得先看 |\n| 红色角标 | 这件事不能等 |\n| 无限滚动 | 这里没有自然的终点 |\n\n## 摩擦不是敌人\n\n有些摩擦应该被删掉,例如重复输入和无意义的确认;另一些摩擦则应该被保留,例如发布前的停顿、删除前的复核,以及阅读长文时清晰的段落边界。\n\n```ts\ntype Friction = {\n cost: number;\n protectsIntent: boolean;\n};\n\nconst shouldRemove = (friction: Friction) =>\n friction.cost > 0 && !friction.protectsIntent;\n```\n\n## 把节奏设计进去\n\n我现在更愿意先问三个问题:\n\n1. 用户来到这里时,最需要看见什么?\n2. 哪些信息可以晚一点出现?\n3. 这个流程应该在哪里自然结束?\n\n这些问题没有让界面变得更复杂,反而让许多元素失去了存在的理由。\n\n## 最后的判断\n\n注意力不是取之不尽的资源。产品真正的克制,不是少用一种颜色,而是少向用户索取一次不必要的注意。\n\n脚注也应该像在 Typora 中一样自然工作。[^attention]\n\n[^attention]: 这也是本站选择安静排版、有限强调色和明确阅读边界的原因。"
89
+ },
90
+ {
91
+ "title": "03 · 带着证据发布",
92
+ "slug": "ship-with-evidence",
93
+ "description": "“我改完了”不是完成,“用户可以确认它好了”才是。",
94
+ "date": "2026-07-18T00:00:00.000Z",
95
+ "topic": "独立开发",
96
+ "tags": [
97
+ "验证",
98
+ "发布"
99
+ ],
100
+ "readingTime": "5 分钟",
101
+ "path": "building-in-public/ship-with-evidence",
102
+ "kind": "post",
103
+ "draft": false,
104
+ "sourcePath": "posts/building-in-public/ship-with-evidence.md",
105
+ "content": "发布前最重要的工作不是再写一段漂亮总结,而是找到足以支持结论的证据。\n\n## 验证真实路径\n\n测试最小单元很重要,但用户真正经历的是一条完整路径。至少有一次验证应该从入口走到结果。\n\n## 不隐藏边界\n\n没有验证的部分要明确说出来。诚实的边界比虚假的完整更有价值。\n\n## 留下下一步\n\n一次发布应该让下一次开发更容易开始,而不是重新理解所有上下文。"
106
+ },
107
+ {
108
+ "title": "我为什么重新选择本地写作",
109
+ "slug": "local-first-writing",
110
+ "description": "Markdown 文件、可迁移的媒体资源,以及不被平台锁住的长期写作。",
111
+ "date": "2026-07-12T00:00:00.000Z",
112
+ "topic": "写作系统",
113
+ "tags": [
114
+ "Markdown",
115
+ "Local-first"
116
+ ],
117
+ "pinned": true,
118
+ "readingTime": "5 分钟",
119
+ "cover": "https://images.unsplash.com/photo-1501525771695-688643efeea4?auto=format&fit=crop&w=1600&q=84",
120
+ "coverAlt": "木桌上的笔记本与钢笔",
121
+ "path": "local-first-writing",
122
+ "kind": "post",
123
+ "draft": false,
124
+ "sourcePath": "posts/local-first-writing.md",
125
+ "content": "我仍然在 Typora 里写下第一版。不是因为它有最多的功能,而是因为一个普通的 Markdown 文件让我知道:十年后,我仍然能打开这些文字。\n\n## 文件就是作品\n\n文章不应该只存在于某个编辑器的数据库里。标题、发布日期和主题放进 YAML front matter,正文保持标准 Markdown,图片则和文章一起归档。\n\n## 发布只是一次编译\n\n写作和发布可以分离。写作阶段只关心内容;网站读取文件,生成目录、搜索索引和 RSS。\n\n- [x] 在 Typora 中完成正文\n- [x] 提交 Markdown 与媒体资源\n- [ ] 发布后继续修正\n\n## 可迁移才有长期主义\n\n真正的长期主义不是选中一个“永远不会倒闭”的平台,而是让迁移成本始终足够低。"
126
+ },
127
+ {
128
+ "title": "02 · 让状态变得可靠",
129
+ "slug": "stable-state",
130
+ "description": "状态设计的关键,是明确谁拥有事实。",
131
+ "date": "2026-07-10T00:00:00.000Z",
132
+ "topic": "独立开发",
133
+ "tags": [
134
+ "状态管理"
135
+ ],
136
+ "readingTime": "6 分钟",
137
+ "path": "building-in-public/stable-state",
138
+ "kind": "post",
139
+ "draft": false,
140
+ "sourcePath": "posts/building-in-public/stable-state.md",
141
+ "content": "状态问题很少只是“少刷新了一次”。更常见的原因是多个地方都以为自己拥有最终事实。\n\n## 唯一事实来源\n\n先确定数据由谁拥有,再决定界面如何派生。临时交互状态可以留在视图,持久事实必须有明确边界。\n\n## 乐观更新\n\n乐观更新让操作显得即时,但失败时必须能够回滚,并且不能让选择状态丢失。\n\n## 可恢复\n\n真正可靠的状态不是永不失败,而是在失败以后仍然知道应该回到哪里。"
142
+ },
143
+ {
144
+ "title": "01 · 从最小闭环开始",
145
+ "slug": "smallest-closed-loop",
146
+ "description": "先让最重要的一条路径真正跑通。",
147
+ "date": "2026-07-04T00:00:00.000Z",
148
+ "topic": "独立开发",
149
+ "tags": [
150
+ "产品",
151
+ "工程"
152
+ ],
153
+ "readingTime": "7 分钟",
154
+ "path": "building-in-public/smallest-closed-loop",
155
+ "kind": "post",
156
+ "draft": false,
157
+ "sourcePath": "posts/building-in-public/smallest-closed-loop.md",
158
+ "content": "一个人的时间有限,所以“完整”必须被重新定义。对我来说,完整不是一次做完所有功能,而是先让最重要的一条路径首尾相接。\n\n## 什么是最小闭环\n\n它必须包含触发、完成和反馈。只有按钮不算闭环,只有数据结构也不算。\n\n## 把想法切成动作\n\n以一个文件浏览器为例,第一步不是标签、搜索或批量操作,而是:\n\n1. 创建文件夹;\n2. 创建 Markdown 文件;\n3. 刷新列表;\n4. 自动选中新项目。\n\n## 可观察的完成\n\n闭环需要能被人看见,也能被测试验证。每次完成之后,都应该留下足够明确的证据。\n\n## 下一步\n\n当闭环成立,再进入命名、删除和上下文菜单。功能扩展不再是猜测,而是沿着已经成立的路径自然生长。"
159
+ },
160
+ {
161
+ "title": "做一个 PDF 阅读器之后,我重新理解了「位置」",
162
+ "slug": "building-a-pdf-reader",
163
+ "description": "阅读位置不是页码,而是一段连续体验的锚点。",
164
+ "date": "2026-06-28T00:00:00.000Z",
165
+ "topic": "构建手记",
166
+ "tags": [
167
+ "SwiftUI",
168
+ "阅读体验"
169
+ ],
170
+ "pinned": false,
171
+ "readingTime": "8 分钟",
172
+ "cover": "https://images.unsplash.com/photo-1623276527153-fa38c1616b05?auto=format&fit=crop&w=1600&q=84",
173
+ "coverAlt": "桌上的平板电脑、笔记本电脑与咖啡",
174
+ "path": "building-a-pdf-reader",
175
+ "kind": "post",
176
+ "draft": false,
177
+ "sourcePath": "posts/building-a-pdf-reader.md",
178
+ "content": "“打开侧栏不能让正文跳一下”看起来是一条微小的要求,真正实现时却牵动了布局、缩放、滚动和状态恢复。\n\n## 页码不等于位置\n\n同一页里也有阅读进度。只记录页码,恢复后仍然可能让人失去上下文。\n\n## 变化前先保存锚点\n\n布局变化之前,保存视口中心所对应的文档坐标;布局完成以后,再把这个坐标映射回新的视口。\n\n## 两条交互路径\n\n侧栏开关是离散变化,分隔线拖拽是连续变化。它们应该共享“保持锚点”的原则,但不应该共享完全相同的更新频率。"
179
+ },
180
+ {
181
+ "title": "从逐词生成理解 KV Cache",
182
+ "slug": "understand-kv-cache",
183
+ "description": "不从公式开始,而是从模型下一次到底重复算了什么开始。",
184
+ "date": "2026-06-09T00:00:00.000Z",
185
+ "topic": "AI 学习",
186
+ "tags": [
187
+ "Transformer",
188
+ "推理"
189
+ ],
190
+ "pinned": false,
191
+ "readingTime": "9 分钟",
192
+ "cover": "https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1600&q=84",
193
+ "coverAlt": "近距离拍摄的电子电路板",
194
+ "path": "understand-kv-cache",
195
+ "kind": "post",
196
+ "draft": false,
197
+ "sourcePath": "posts/understand-kv-cache.md",
198
+ "content": "理解 KV Cache 最直观的方式,是先看自回归生成里那些被重复计算的部分。\n\n朴素 decode 每步都会把过去 token 再投影一遍。6 个 token 共 21 次计算,其中 15 次是浪费:\n\n```widget\nsrc: /media/posts/understand-kv-cache/waste.html\ncaption: 朴素 decode 每步重算全部过去 token;6 个 token 共 21 次计算,其中 15 次是浪费。\nkind: chart\n```\n\n## Prefill 与 Decode\n\nPrefill 一次处理完整提示词,Decode 则每次只生成一个新 token。拓扑用 Mermaid 一次说清;要停在某一拍看 Q/K/V 怎么变,用旁边的步进图。\n\n```mermaid\nflowchart LR\n A[Prompt] --> B[Prefill]\n B --> C[KV Cache]\n C --> D[Decode one token]\n D --> C\n```\n\n```widget\nsrc: /media/posts/understand-kv-cache/stepper.html\ncaption: Prefill 一次写满 K/V;Decode 每步只追加一行;末步 21 次投影变成 7 次。\nkind: chart\n```\n\n## 缓存的是什么\n\n过去 token 对应的 Key 和 Value 不会因为新 token 到来而变化,所以可以保存下来,只计算新位置的投影。\n\n## 空间换时间\n\n缓存减少计算,却增加显存占用。批量、序列长度与层数共同决定了这笔开销。把 batch 拧到 8、序列看到 32K,消费卡会先被 KV 撑满。\n\n$$\n\\text{KV memory} \\propto 2 \\times L \\times H \\times T\n$$\n\n```widget\nsrc: /media/posts/understand-kv-cache/memory.html\ncaption: 70B 在长上下文下,KV 显存会先于权重撑满一张卡;batch 8 × 32K 时连 7B 也超过消费卡。\nkind: chart\n```\n\n同一组数量级也可以收成一页附件。页面和 PDF 默认只显示入口,需要时再展开:\n\n```widget\nsrc: /media/posts/understand-kv-cache/sheet.html\ncaption: KV 显存与层数、头维、序列和 batch 成正比;70B 长上下文会先于权重量满一张卡。\nkind: html\n```\n\n```widget\nsrc: /media/posts/understand-kv-cache/notes.pdf\ncaption: 同一页公式的 PDF;适合下载或打印,不必在正文里摊开整页阅读器。\nkind: pdf\n```"
199
+ }
200
+ ] as ContentEntry[];
201
+ export const columns = [
202
+ {
203
+ "title": "01 · 从最小闭环开始",
204
+ "slug": "smallest-closed-loop",
205
+ "description": "先让最重要的一条路径真正跑通。",
206
+ "date": "2026-07-04T00:00:00.000Z",
207
+ "topic": "独立开发",
208
+ "tags": [
209
+ "产品",
210
+ "工程"
211
+ ],
212
+ "readingTime": "7 分钟",
213
+ "path": "building-in-public/smallest-closed-loop",
214
+ "kind": "column",
215
+ "draft": false,
216
+ "sourcePath": "posts/building-in-public/smallest-closed-loop.md",
217
+ "content": "一个人的时间有限,所以“完整”必须被重新定义。对我来说,完整不是一次做完所有功能,而是先让最重要的一条路径首尾相接。\n\n## 什么是最小闭环\n\n它必须包含触发、完成和反馈。只有按钮不算闭环,只有数据结构也不算。\n\n## 把想法切成动作\n\n以一个文件浏览器为例,第一步不是标签、搜索或批量操作,而是:\n\n1. 创建文件夹;\n2. 创建 Markdown 文件;\n3. 刷新列表;\n4. 自动选中新项目。\n\n## 可观察的完成\n\n闭环需要能被人看见,也能被测试验证。每次完成之后,都应该留下足够明确的证据。\n\n## 下一步\n\n当闭环成立,再进入命名、删除和上下文菜单。功能扩展不再是猜测,而是沿着已经成立的路径自然生长。",
218
+ "column": "building-in-public",
219
+ "columnTitle": "一个人造产品",
220
+ "columnDescription": "从最小闭环、可靠状态到带着证据发布:记录一个人把产品从想法变成日常工具的过程。",
221
+ "columnStatus": "连载中",
222
+ "order": 1
223
+ },
224
+ {
225
+ "title": "02 · 让状态变得可靠",
226
+ "slug": "stable-state",
227
+ "description": "状态设计的关键,是明确谁拥有事实。",
228
+ "date": "2026-07-10T00:00:00.000Z",
229
+ "topic": "独立开发",
230
+ "tags": [
231
+ "状态管理"
232
+ ],
233
+ "readingTime": "6 分钟",
234
+ "path": "building-in-public/stable-state",
235
+ "kind": "column",
236
+ "draft": false,
237
+ "sourcePath": "posts/building-in-public/stable-state.md",
238
+ "content": "状态问题很少只是“少刷新了一次”。更常见的原因是多个地方都以为自己拥有最终事实。\n\n## 唯一事实来源\n\n先确定数据由谁拥有,再决定界面如何派生。临时交互状态可以留在视图,持久事实必须有明确边界。\n\n## 乐观更新\n\n乐观更新让操作显得即时,但失败时必须能够回滚,并且不能让选择状态丢失。\n\n## 可恢复\n\n真正可靠的状态不是永不失败,而是在失败以后仍然知道应该回到哪里。",
239
+ "column": "building-in-public",
240
+ "columnTitle": "一个人造产品",
241
+ "columnDescription": "从最小闭环、可靠状态到带着证据发布:记录一个人把产品从想法变成日常工具的过程。",
242
+ "columnStatus": "连载中",
243
+ "order": 2
244
+ },
245
+ {
246
+ "title": "03 · 带着证据发布",
247
+ "slug": "ship-with-evidence",
248
+ "description": "“我改完了”不是完成,“用户可以确认它好了”才是。",
249
+ "date": "2026-07-18T00:00:00.000Z",
250
+ "topic": "独立开发",
251
+ "tags": [
252
+ "验证",
253
+ "发布"
254
+ ],
255
+ "readingTime": "5 分钟",
256
+ "path": "building-in-public/ship-with-evidence",
257
+ "kind": "column",
258
+ "draft": false,
259
+ "sourcePath": "posts/building-in-public/ship-with-evidence.md",
260
+ "content": "发布前最重要的工作不是再写一段漂亮总结,而是找到足以支持结论的证据。\n\n## 验证真实路径\n\n测试最小单元很重要,但用户真正经历的是一条完整路径。至少有一次验证应该从入口走到结果。\n\n## 不隐藏边界\n\n没有验证的部分要明确说出来。诚实的边界比虚假的完整更有价值。\n\n## 留下下一步\n\n一次发布应该让下一次开发更容易开始,而不是重新理解所有上下文。",
261
+ "column": "building-in-public",
262
+ "columnTitle": "一个人造产品",
263
+ "columnDescription": "从最小闭环、可靠状态到带着证据发布:记录一个人把产品从想法变成日常工具的过程。",
264
+ "columnStatus": "连载中",
265
+ "order": 3
266
+ }
267
+ ] as ContentEntry[];
268
+ export const thoughts = [
269
+ {
270
+ "title": "雨后的城市",
271
+ "slug": "rainy-night",
272
+ "date": "2026-07-24T14:36:00.000Z",
273
+ "tags": [
274
+ "摄影"
275
+ ],
276
+ "mediaType": "text",
277
+ "media": [
278
+ {
279
+ "type": "image",
280
+ "src": "https://images.unsplash.com/photo-1537351685860-1e89e9ecb843?auto=format&fit=crop&w=1400&q=84",
281
+ "alt": "雨后城市街道与灯光倒影"
282
+ },
283
+ {
284
+ "type": "image",
285
+ "src": "https://images.unsplash.com/photo-1630234674857-527eb5534837?auto=format&fit=crop&w=1200&q=82",
286
+ "alt": "彩色建筑立面"
287
+ },
288
+ {
289
+ "type": "image",
290
+ "src": "https://images.unsplash.com/photo-1501525771695-688643efeea4?auto=format&fit=crop&w=1200&q=82",
291
+ "alt": "木桌上的笔记本与钢笔"
292
+ },
293
+ {
294
+ "type": "audio",
295
+ "src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
296
+ "title": "雨夜播放列表 · 片段"
297
+ },
298
+ {
299
+ "type": "video",
300
+ "src": "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
301
+ "poster": "https://images.unsplash.com/photo-1544655205-dcbeb868c648?auto=format&fit=crop&w=1400&q=82"
302
+ },
303
+ {
304
+ "type": "link",
305
+ "src": "https://indieweb.org/Webmention",
306
+ "title": "Webmention · IndieWeb",
307
+ "description": "一条和这晚阅读有关、值得保留上下文的链接。"
308
+ }
309
+ ],
310
+ "path": "rainy-night",
311
+ "kind": "thought",
312
+ "draft": false,
313
+ "sourcePath": "thoughts/rainy-night.md",
314
+ "content": "下过雨的路面会把城市复制一遍。上面那个版本负责赶路,下面那个版本只负责发光。\n\n这一条随想故意把三张照片、一段声音、一段视频和一个链接放在一起,用来确认日常记录不需要被单一媒体类型限制。"
315
+ },
316
+ {
317
+ "title": "今天的声音",
318
+ "slug": "field-recording",
319
+ "date": "2026-07-22T10:10:00.000Z",
320
+ "tags": [
321
+ "声音"
322
+ ],
323
+ "mediaType": "audio",
324
+ "media": [
325
+ {
326
+ "type": "audio",
327
+ "src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
328
+ "title": "今天的声音 · 循环片段"
329
+ }
330
+ ],
331
+ "path": "field-recording",
332
+ "kind": "thought",
333
+ "draft": false,
334
+ "sourcePath": "thoughts/field-recording.md",
335
+ "content": "写代码时循环播放的一段声音。相比歌单,我最近更喜欢没有明确歌词的背景,它不会替思考决定方向。"
336
+ },
337
+ {
338
+ "title": "慢一点看",
339
+ "slug": "slow-motion",
340
+ "date": "2026-07-19T01:20:00.000Z",
341
+ "tags": [
342
+ "影像"
343
+ ],
344
+ "mediaType": "video",
345
+ "media": [
346
+ {
347
+ "type": "video",
348
+ "src": "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
349
+ "poster": "https://images.unsplash.com/photo-1544655205-dcbeb868c648?auto=format&fit=crop&w=1400&q=82"
350
+ }
351
+ ],
352
+ "path": "slow-motion",
353
+ "kind": "thought",
354
+ "draft": false,
355
+ "sourcePath": "thoughts/slow-motion.md",
356
+ "content": "很多细节不是不存在,只是平常的速度太快了。"
357
+ },
358
+ {
359
+ "title": "链接不是收藏,是路径",
360
+ "slug": "links-are-paths",
361
+ "date": "2026-07-16T04:05:00.000Z",
362
+ "tags": [
363
+ "阅读"
364
+ ],
365
+ "mediaType": "link",
366
+ "media": [
367
+ {
368
+ "type": "link",
369
+ "src": "https://indieweb.org/Webmention",
370
+ "title": "Webmention · IndieWeb",
371
+ "description": "让不同网站之间的回复、喜欢与引用重新连接起来。"
372
+ }
373
+ ],
374
+ "path": "links-are-paths",
375
+ "kind": "thought",
376
+ "draft": false,
377
+ "sourcePath": "thoughts/links-are-paths.md",
378
+ "content": "想把最近读到的好东西放回来。收藏夹很容易变成仓库,链接出现在上下文里,才重新变成一条路。"
379
+ }
380
+ ] as ContentEntry[];
@@ -0,0 +1,244 @@
1
+ import matter from "gray-matter";
2
+ import type { ContentEntry, MediaItem } from "./content";
3
+ import { repositoryPostPath } from "./repository-path.mjs";
4
+
5
+ const apiBase = "https://api.github.com";
6
+
7
+ type RepositoryFile = {
8
+ content?: string;
9
+ encoding?: string;
10
+ sha?: string;
11
+ };
12
+
13
+ type RepositoryDirectoryEntry = {
14
+ name: string;
15
+ path: string;
16
+ type: "file" | "dir";
17
+ };
18
+
19
+ export type RepositoryThought = ContentEntry & {
20
+ id: string;
21
+ status: "draft" | "published";
22
+ sha?: string;
23
+ };
24
+
25
+ function repositoryConfig() {
26
+ const repository = process.env.CONTENT_REPOSITORY;
27
+ const token = process.env.CONTENT_GITHUB_TOKEN;
28
+ const branch = process.env.CONTENT_BRANCH ?? "main";
29
+ if (!repository || !token) return null;
30
+ return { repository, token, branch };
31
+ }
32
+
33
+ export function isContentRepositoryConfigured() {
34
+ return Boolean(repositoryConfig());
35
+ }
36
+
37
+ function headers(token: string) {
38
+ return {
39
+ Accept: "application/vnd.github+json",
40
+ Authorization: `Bearer ${token}`,
41
+ "User-Agent": "Markdown-Blog-Admin",
42
+ "X-GitHub-Api-Version": "2022-11-28",
43
+ };
44
+ }
45
+
46
+ function utf8ToBase64(value: string) {
47
+ const bytes = new TextEncoder().encode(value);
48
+ let binary = "";
49
+ bytes.forEach((byte) => {
50
+ binary += String.fromCharCode(byte);
51
+ });
52
+ return btoa(binary);
53
+ }
54
+
55
+ function base64ToUtf8(value: string) {
56
+ const binary = atob(value.replace(/\s/gu, ""));
57
+ return new TextDecoder().decode(
58
+ Uint8Array.from(binary, (character) => character.charCodeAt(0)),
59
+ );
60
+ }
61
+
62
+ function isStringArray(value: unknown): value is string[] {
63
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
64
+ }
65
+
66
+ function isMedia(value: unknown): value is MediaItem[] {
67
+ return Array.isArray(value) && value.every((item) => {
68
+ if (!item || typeof item !== "object") return false;
69
+ const candidate = item as Record<string, unknown>;
70
+ return ["image", "audio", "video", "link"].includes(String(candidate.type)) &&
71
+ typeof candidate.src === "string";
72
+ });
73
+ }
74
+
75
+ function normalizeThought(source: string, path: string, sha?: string): RepositoryThought | null {
76
+ const parsed = matter(source);
77
+ const data = parsed.data;
78
+ if (typeof data.title !== "string" || typeof data.slug !== "string" || typeof data.date !== "string") {
79
+ return null;
80
+ }
81
+ const media = isMedia(data.media) ? data.media : [];
82
+ const status = data.draft === true ? "draft" : "published";
83
+ return {
84
+ id: data.slug,
85
+ slug: data.slug,
86
+ title: data.title,
87
+ date: data.date,
88
+ tags: isStringArray(data.tags) ? data.tags : [],
89
+ mediaType: media[0]?.type ?? "text",
90
+ media,
91
+ kind: "thought",
92
+ draft: status === "draft",
93
+ sourcePath: path,
94
+ content: parsed.content.trim(),
95
+ status,
96
+ sha,
97
+ };
98
+ }
99
+
100
+ function serializeThought(thought: {
101
+ slug: string;
102
+ title: string;
103
+ content: string;
104
+ tags: string[];
105
+ media: MediaItem[];
106
+ status: "draft" | "published";
107
+ date?: string;
108
+ }) {
109
+ return matter.stringify(thought.content.trim().concat("\n"), {
110
+ title: thought.title,
111
+ slug: thought.slug,
112
+ date: thought.date ?? new Date().toISOString(),
113
+ tags: thought.tags,
114
+ mediaType: thought.media[0]?.type ?? "text",
115
+ media: thought.media,
116
+ draft: thought.status === "draft",
117
+ });
118
+ }
119
+
120
+ async function requestRepository(path: string, init?: RequestInit) {
121
+ const config = repositoryConfig();
122
+ if (!config) throw new Error("未配置 CONTENT_REPOSITORY 或 CONTENT_GITHUB_TOKEN。");
123
+ const url = new URL(`${apiBase}/repos/${config.repository}/contents/${path}`);
124
+ if (!init?.method || init.method === "GET") url.searchParams.set("ref", config.branch);
125
+ return {
126
+ config,
127
+ response: await fetch(url, {
128
+ ...init,
129
+ headers: { ...headers(config.token), ...init?.headers },
130
+ cache: "no-store",
131
+ }),
132
+ };
133
+ }
134
+
135
+ async function mutateRepository(
136
+ path: string,
137
+ method: "PUT" | "DELETE",
138
+ payload: Record<string, unknown>,
139
+ ) {
140
+ const config = repositoryConfig();
141
+ if (!config) throw new Error("未配置 CONTENT_REPOSITORY 或 CONTENT_GITHUB_TOKEN。");
142
+ return requestRepository(path, {
143
+ method,
144
+ body: JSON.stringify({ ...payload, branch: config.branch }),
145
+ });
146
+ }
147
+
148
+ async function readRepositoryFile(path: string) {
149
+ const { response } = await requestRepository(path);
150
+ if (response.status === 404) return null;
151
+ if (!response.ok) throw new Error("无法读取 GitHub 内容仓库。");
152
+ const file = (await response.json()) as RepositoryFile;
153
+ if (file.encoding !== "base64" || !file.content || !file.sha) {
154
+ throw new Error("GitHub 返回的内容文件格式不正确。");
155
+ }
156
+ return { source: base64ToUtf8(file.content), sha: file.sha };
157
+ }
158
+
159
+ export async function listRepositoryThoughts() {
160
+ const config = repositoryConfig();
161
+ if (!config) return [] as RepositoryThought[];
162
+ const { response } = await requestRepository("content/thoughts");
163
+ if (response.status === 404) return [] as RepositoryThought[];
164
+ if (!response.ok) throw new Error("无法读取 GitHub 随想目录。");
165
+ const entries = (await response.json()) as RepositoryDirectoryEntry[];
166
+ const thoughts = await Promise.all(
167
+ entries
168
+ .filter((entry) => entry.type === "file" && entry.name.endsWith(".md"))
169
+ .map(async (entry) => {
170
+ const file = await readRepositoryFile(entry.path);
171
+ return file ? normalizeThought(file.source, entry.path, file.sha) : null;
172
+ }),
173
+ );
174
+ return thoughts
175
+ .filter((thought): thought is RepositoryThought => Boolean(thought))
176
+ .sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
177
+ }
178
+
179
+ export async function createRepositoryThought(input: Omit<RepositoryThought, "id" | "kind" | "sourcePath" | "draft" | "sha" | "mediaType">) {
180
+ const path = `content/thoughts/${input.slug}.md`;
181
+ const existing = await readRepositoryFile(path);
182
+ if (existing) throw new Error("这个随想 slug 已存在,请稍后重试。");
183
+ const source = serializeThought(input);
184
+ const { response } = await mutateRepository(path, "PUT", {
185
+ message: `content: 发布随想 ${input.title}`,
186
+ content: utf8ToBase64(source),
187
+ });
188
+ if (!response.ok) throw new Error("GitHub 未接受这次随想提交。");
189
+ const result = (await response.json()) as { content?: { sha?: string } };
190
+ return normalizeThought(source, path, result.content?.sha) as RepositoryThought;
191
+ }
192
+
193
+ export async function updateRepositoryThought(slug: string, status: "draft" | "published") {
194
+ const path = `content/thoughts/${slug}.md`;
195
+ const existing = await readRepositoryFile(path);
196
+ if (!existing) throw new Error("未找到对应随想。");
197
+ const thought = normalizeThought(existing.source, path, existing.sha);
198
+ if (!thought) throw new Error("随想内容格式不正确。");
199
+ const source = serializeThought({ ...thought, status, date: status === "published" ? new Date().toISOString() : thought.date });
200
+ const { response } = await mutateRepository(path, "PUT", {
201
+ message: `content: ${status === "published" ? "发布" : "撤回"}随想 ${thought.title}`,
202
+ content: utf8ToBase64(source),
203
+ sha: existing.sha,
204
+ });
205
+ if (!response.ok) throw new Error("GitHub 未接受这次状态更新。");
206
+ return normalizeThought(source, path) as RepositoryThought;
207
+ }
208
+
209
+ export async function deleteRepositoryThought(slug: string) {
210
+ const path = `content/thoughts/${slug}.md`;
211
+ const existing = await readRepositoryFile(path);
212
+ if (!existing) throw new Error("未找到对应随想。");
213
+ const { response } = await mutateRepository(path, "DELETE", {
214
+ message: `content: 删除随想 ${slug}`,
215
+ sha: existing.sha,
216
+ });
217
+ if (!response.ok) throw new Error("GitHub 未接受这次删除请求。");
218
+ }
219
+
220
+ export async function updateRepositoryPostPinned(input: {
221
+ sourcePath: string;
222
+ slug: string;
223
+ title: string;
224
+ pinned: boolean;
225
+ }) {
226
+ const repositoryPath = repositoryPostPath(input.sourcePath);
227
+ const existing = await readRepositoryFile(repositoryPath);
228
+ if (!existing) throw new Error("未找到对应博文。");
229
+ const parsed = matter(existing.source);
230
+ if (parsed.data.slug !== input.slug) {
231
+ throw new Error("博文标识不匹配,已拒绝修改。");
232
+ }
233
+ const data = { ...parsed.data } as Record<string, unknown>;
234
+ if (input.pinned) data.pinned = true;
235
+ else delete data.pinned;
236
+ const source = matter.stringify(`${parsed.content.trim()}\n`, data);
237
+ const { response } = await mutateRepository(repositoryPath, "PUT", {
238
+ message: `content: ${input.pinned ? "置顶" : "取消置顶"}博文 ${input.title}`,
239
+ content: utf8ToBase64(source),
240
+ sha: existing.sha,
241
+ });
242
+ if (!response.ok) throw new Error("GitHub 未接受这次置顶状态更新。");
243
+ return { slug: input.slug, pinned: input.pinned };
244
+ }
@@ -0,0 +1,13 @@
1
+ import remarkGfm from "remark-gfm";
2
+ import remarkCjkFriendly from "remark-cjk-friendly";
3
+ import remarkBreaks from "remark-breaks";
4
+ import remarkMath from "remark-math";
5
+ import type { PluggableList } from "unified";
6
+
7
+ // Keep article rendering and heading extraction on the same Markdown syntax.
8
+ export const markdownPlugins: PluggableList = [
9
+ remarkGfm,
10
+ remarkCjkFriendly,
11
+ remarkBreaks,
12
+ remarkMath,
13
+ ];