hd-garden 0.3.3 → 0.3.4

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.
@@ -48,7 +48,7 @@ export async function generateMetadata({
48
48
  ? new Date(post.updated).toISOString()
49
49
  : undefined,
50
50
  tags: post.tags,
51
- images: post.cover ? [{ url: post.cover, alt: post.coverAlt }] : undefined,
51
+ images: post.cover ? [{ url: post.cover, alt: post.title }] : undefined,
52
52
  },
53
53
  twitter: {
54
54
  card: "summary_large_image",
@@ -92,7 +92,7 @@ export default async function PostPage({
92
92
  <img
93
93
  className="u-photo"
94
94
  src={post.cover}
95
- alt={post.coverAlt ?? `${post.title} 封面`}
95
+ alt={`${post.title} 封面`}
96
96
  />
97
97
  </figure>
98
98
  )}
@@ -52,7 +52,7 @@ export async function generateMetadata({
52
52
  ? new Date(entry.updated).toISOString()
53
53
  : undefined,
54
54
  tags: entry.tags,
55
- images: entry.cover ? [{ url: entry.cover, alt: entry.coverAlt }] : undefined,
55
+ images: entry.cover ? [{ url: entry.cover, alt: entry.title }] : undefined,
56
56
  },
57
57
  twitter: {
58
58
  card: "summary_large_image",
@@ -115,7 +115,7 @@ export default async function ColumnEntryPage({
115
115
  <img
116
116
  className="u-photo"
117
117
  src={entry.cover}
118
- alt={entry.coverAlt ?? `${entry.title} 封面`}
118
+ alt={`${entry.title} 封面`}
119
119
  />
120
120
  </figure>
121
121
  )}
@@ -69,7 +69,7 @@ export function BlogArchive() {
69
69
  <img
70
70
  className="archive-cover"
71
71
  src={post.cover}
72
- alt={post.coverAlt ?? `${post.title} 封面`}
72
+ alt={`${post.title} 封面`}
73
73
  loading="lazy"
74
74
  />
75
75
  )}
@@ -26,13 +26,12 @@ function SourcePanel({ path, source, language }: SourcePanelProps) {
26
26
 
27
27
  const postSource = `---
28
28
  title: 我的第一篇文章
29
- slug: first-post
30
29
  date: 2026-08-03T09:00:00+08:00
31
30
  description: 这篇文章记录我为什么重新开始写博客。
32
31
  topic: 写作
33
32
  tags: [Markdown, 博客]
34
33
  pinned: false
35
- cover: ./assets/cover.jpg
34
+ cover: writing/first-post.jpg
36
35
  ---
37
36
 
38
37
  正文从这里开始。
@@ -43,7 +42,6 @@ cover: ./assets/cover.jpg
43
42
 
44
43
  const thoughtSource = `---
45
44
  title: 雨后的散步
46
- slug: walk-after-rain
47
45
  date: 2026-08-03T18:30:00+08:00
48
46
  tags: [生活]
49
47
  media:
@@ -121,9 +119,9 @@ const guides: Guide[] = [
121
119
  "随想适合记录几句话、一个链接或一组多媒体。它同样保存在 Git 仓库中,只需要更少的字段,也可以通过可选管理后台发布。",
122
120
  steps: [
123
121
  <>
124
- 直接创建 <code>content/thoughts/&lt;slug&gt;.md</code>,或者在管理后台填写内容。
122
+ 直接创建 <code>content/thoughts/&lt;文件名&gt;.md</code>,或者在管理后台填写内容。
125
123
  </>,
126
- <>填写标题、唯一 slug 和日期;需要暂存时加入草稿状态。</>,
124
+ <>文件名就是地址。front matter 填写标题和日期;需要暂存时加入草稿状态。</>,
127
125
  <>
128
126
  在 <code>media</code> 中按顺序添加图片、音频、视频或外部链接。
129
127
  </>,
@@ -33,7 +33,6 @@ declare module "@garden/generated-content" {
33
33
  columnCoverAlt?: string;
34
34
  order?: number;
35
35
  cover?: string;
36
- coverAlt?: string;
37
36
  mediaType?: "image" | "audio" | "video" | "link" | "text";
38
37
  media?: string | MediaItem[];
39
38
  mediaAlt?: string;
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-content.mjs. Edit files in content/ instead.
1
+ // Generated by garden sync. Edit files in content/ instead.
2
2
  export type MediaItem = {
3
3
  type: "image" | "audio" | "video" | "link";
4
4
  src: string;
@@ -33,7 +33,6 @@ export type ContentEntry = {
33
33
  columnCoverAlt?: string;
34
34
  order?: number;
35
35
  cover?: string;
36
- coverAlt?: string;
37
36
  mediaType?: "image" | "audio" | "video" | "link" | "text";
38
37
  media?: string | MediaItem[];
39
38
  mediaAlt?: string;
@@ -42,11 +41,10 @@ export type ContentEntry = {
42
41
  linkDescription?: string;
43
42
  };
44
43
 
45
- export const contentHash = "d29583878502";
44
+ export const contentHash = "86dad25b6dba";
46
45
  export const posts = [
47
46
  {
48
47
  "title": "Markdown 复杂语法实验场",
49
- "slug": "markdown-lab",
50
48
  "description": "用一篇真实文章验证 Typora 常用语法、数学公式、Mermaid、脚注、表格与原生 HTML。",
51
49
  "date": "2026-07-25T00:00:00.000Z",
52
50
  "topic": "写作系统",
@@ -59,7 +57,7 @@ export const posts = [
59
57
  "pinned": false,
60
58
  "readingTime": "10 分钟",
61
59
  "cover": "https://images.unsplash.com/photo-1761414500824-e280de5a1b37?auto=format&fit=crop&w=1600&q=84",
62
- "coverAlt": "彩色阳台构成的现代建筑立面",
60
+ "slug": "markdown-lab",
63
61
  "path": "markdown-lab",
64
62
  "kind": "post",
65
63
  "draft": false,
@@ -68,7 +66,6 @@ export const posts = [
68
66
  },
69
67
  {
70
68
  "title": "注意力也是一种界面",
71
- "slug": "attention-is-an-interface",
72
69
  "description": "当工具越来越聪明,真正稀缺的不是功能,而是我们把注意力放在哪里。",
73
70
  "date": "2026-07-21T00:00:00.000Z",
74
71
  "updated": "2026-07-23T00:00:00.000Z",
@@ -80,7 +77,7 @@ export const posts = [
80
77
  "pinned": true,
81
78
  "readingTime": "6 分钟",
82
79
  "cover": "https://images.unsplash.com/photo-1630234674857-527eb5534837?auto=format&fit=crop&w=1600&q=84",
83
- "coverAlt": "彩色几何建筑立面",
80
+ "slug": "attention-is-an-interface",
84
81
  "path": "attention-is-an-interface",
85
82
  "kind": "post",
86
83
  "draft": false,
@@ -89,7 +86,6 @@ export const posts = [
89
86
  },
90
87
  {
91
88
  "title": "03 · 带着证据发布",
92
- "slug": "ship-with-evidence",
93
89
  "description": "“我改完了”不是完成,“用户可以确认它好了”才是。",
94
90
  "date": "2026-07-18T00:00:00.000Z",
95
91
  "topic": "独立开发",
@@ -98,6 +94,7 @@ export const posts = [
98
94
  "发布"
99
95
  ],
100
96
  "readingTime": "5 分钟",
97
+ "slug": "ship-with-evidence",
101
98
  "path": "building-in-public/ship-with-evidence",
102
99
  "kind": "post",
103
100
  "draft": false,
@@ -106,7 +103,6 @@ export const posts = [
106
103
  },
107
104
  {
108
105
  "title": "我为什么重新选择本地写作",
109
- "slug": "local-first-writing",
110
106
  "description": "Markdown 文件、可迁移的媒体资源,以及不被平台锁住的长期写作。",
111
107
  "date": "2026-07-12T00:00:00.000Z",
112
108
  "topic": "写作系统",
@@ -117,7 +113,7 @@ export const posts = [
117
113
  "pinned": true,
118
114
  "readingTime": "5 分钟",
119
115
  "cover": "https://images.unsplash.com/photo-1501525771695-688643efeea4?auto=format&fit=crop&w=1600&q=84",
120
- "coverAlt": "木桌上的笔记本与钢笔",
116
+ "slug": "local-first-writing",
121
117
  "path": "local-first-writing",
122
118
  "kind": "post",
123
119
  "draft": false,
@@ -126,7 +122,6 @@ export const posts = [
126
122
  },
127
123
  {
128
124
  "title": "02 · 让状态变得可靠",
129
- "slug": "stable-state",
130
125
  "description": "状态设计的关键,是明确谁拥有事实。",
131
126
  "date": "2026-07-10T00:00:00.000Z",
132
127
  "topic": "独立开发",
@@ -134,6 +129,7 @@ export const posts = [
134
129
  "状态管理"
135
130
  ],
136
131
  "readingTime": "6 分钟",
132
+ "slug": "stable-state",
137
133
  "path": "building-in-public/stable-state",
138
134
  "kind": "post",
139
135
  "draft": false,
@@ -142,7 +138,6 @@ export const posts = [
142
138
  },
143
139
  {
144
140
  "title": "01 · 从最小闭环开始",
145
- "slug": "smallest-closed-loop",
146
141
  "description": "先让最重要的一条路径真正跑通。",
147
142
  "date": "2026-07-04T00:00:00.000Z",
148
143
  "topic": "独立开发",
@@ -151,6 +146,7 @@ export const posts = [
151
146
  "工程"
152
147
  ],
153
148
  "readingTime": "7 分钟",
149
+ "slug": "smallest-closed-loop",
154
150
  "path": "building-in-public/smallest-closed-loop",
155
151
  "kind": "post",
156
152
  "draft": false,
@@ -159,7 +155,6 @@ export const posts = [
159
155
  },
160
156
  {
161
157
  "title": "做一个 PDF 阅读器之后,我重新理解了「位置」",
162
- "slug": "building-a-pdf-reader",
163
158
  "description": "阅读位置不是页码,而是一段连续体验的锚点。",
164
159
  "date": "2026-06-28T00:00:00.000Z",
165
160
  "topic": "构建手记",
@@ -170,7 +165,7 @@ export const posts = [
170
165
  "pinned": false,
171
166
  "readingTime": "8 分钟",
172
167
  "cover": "https://images.unsplash.com/photo-1623276527153-fa38c1616b05?auto=format&fit=crop&w=1600&q=84",
173
- "coverAlt": "桌上的平板电脑、笔记本电脑与咖啡",
168
+ "slug": "building-a-pdf-reader",
174
169
  "path": "building-a-pdf-reader",
175
170
  "kind": "post",
176
171
  "draft": false,
@@ -179,7 +174,6 @@ export const posts = [
179
174
  },
180
175
  {
181
176
  "title": "从逐词生成理解 KV Cache",
182
- "slug": "understand-kv-cache",
183
177
  "description": "不从公式开始,而是从模型下一次到底重复算了什么开始。",
184
178
  "date": "2026-06-09T00:00:00.000Z",
185
179
  "topic": "AI 学习",
@@ -190,7 +184,7 @@ export const posts = [
190
184
  "pinned": false,
191
185
  "readingTime": "9 分钟",
192
186
  "cover": "https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1600&q=84",
193
- "coverAlt": "近距离拍摄的电子电路板",
187
+ "slug": "understand-kv-cache",
194
188
  "path": "understand-kv-cache",
195
189
  "kind": "post",
196
190
  "draft": false,
@@ -201,7 +195,6 @@ export const posts = [
201
195
  export const columns = [
202
196
  {
203
197
  "title": "01 · 从最小闭环开始",
204
- "slug": "smallest-closed-loop",
205
198
  "description": "先让最重要的一条路径真正跑通。",
206
199
  "date": "2026-07-04T00:00:00.000Z",
207
200
  "topic": "独立开发",
@@ -210,6 +203,7 @@ export const columns = [
210
203
  "工程"
211
204
  ],
212
205
  "readingTime": "7 分钟",
206
+ "slug": "smallest-closed-loop",
213
207
  "path": "building-in-public/smallest-closed-loop",
214
208
  "kind": "column",
215
209
  "draft": false,
@@ -223,7 +217,6 @@ export const columns = [
223
217
  },
224
218
  {
225
219
  "title": "02 · 让状态变得可靠",
226
- "slug": "stable-state",
227
220
  "description": "状态设计的关键,是明确谁拥有事实。",
228
221
  "date": "2026-07-10T00:00:00.000Z",
229
222
  "topic": "独立开发",
@@ -231,6 +224,7 @@ export const columns = [
231
224
  "状态管理"
232
225
  ],
233
226
  "readingTime": "6 分钟",
227
+ "slug": "stable-state",
234
228
  "path": "building-in-public/stable-state",
235
229
  "kind": "column",
236
230
  "draft": false,
@@ -244,7 +238,6 @@ export const columns = [
244
238
  },
245
239
  {
246
240
  "title": "03 · 带着证据发布",
247
- "slug": "ship-with-evidence",
248
241
  "description": "“我改完了”不是完成,“用户可以确认它好了”才是。",
249
242
  "date": "2026-07-18T00:00:00.000Z",
250
243
  "topic": "独立开发",
@@ -253,6 +246,7 @@ export const columns = [
253
246
  "发布"
254
247
  ],
255
248
  "readingTime": "5 分钟",
249
+ "slug": "ship-with-evidence",
256
250
  "path": "building-in-public/ship-with-evidence",
257
251
  "kind": "column",
258
252
  "draft": false,
@@ -268,7 +262,6 @@ export const columns = [
268
262
  export const thoughts = [
269
263
  {
270
264
  "title": "雨后的城市",
271
- "slug": "rainy-night",
272
265
  "date": "2026-07-24T14:36:00.000Z",
273
266
  "tags": [
274
267
  "摄影"
@@ -307,6 +300,7 @@ export const thoughts = [
307
300
  "description": "一条和这晚阅读有关、值得保留上下文的链接。"
308
301
  }
309
302
  ],
303
+ "slug": "rainy-night",
310
304
  "path": "rainy-night",
311
305
  "kind": "thought",
312
306
  "draft": false,
@@ -315,7 +309,6 @@ export const thoughts = [
315
309
  },
316
310
  {
317
311
  "title": "今天的声音",
318
- "slug": "field-recording",
319
312
  "date": "2026-07-22T10:10:00.000Z",
320
313
  "tags": [
321
314
  "声音"
@@ -328,6 +321,7 @@ export const thoughts = [
328
321
  "title": "今天的声音 · 循环片段"
329
322
  }
330
323
  ],
324
+ "slug": "field-recording",
331
325
  "path": "field-recording",
332
326
  "kind": "thought",
333
327
  "draft": false,
@@ -336,7 +330,6 @@ export const thoughts = [
336
330
  },
337
331
  {
338
332
  "title": "慢一点看",
339
- "slug": "slow-motion",
340
333
  "date": "2026-07-19T01:20:00.000Z",
341
334
  "tags": [
342
335
  "影像"
@@ -349,6 +342,7 @@ export const thoughts = [
349
342
  "poster": "https://images.unsplash.com/photo-1544655205-dcbeb868c648?auto=format&fit=crop&w=1400&q=82"
350
343
  }
351
344
  ],
345
+ "slug": "slow-motion",
352
346
  "path": "slow-motion",
353
347
  "kind": "thought",
354
348
  "draft": false,
@@ -357,7 +351,6 @@ export const thoughts = [
357
351
  },
358
352
  {
359
353
  "title": "链接不是收藏,是路径",
360
- "slug": "links-are-paths",
361
354
  "date": "2026-07-16T04:05:00.000Z",
362
355
  "tags": [
363
356
  "阅读"
@@ -371,6 +364,7 @@ export const thoughts = [
371
364
  "description": "让不同网站之间的回复、喜欢与引用重新连接起来。"
372
365
  }
373
366
  ],
367
+ "slug": "links-are-paths",
374
368
  "path": "links-are-paths",
375
369
  "kind": "thought",
376
370
  "draft": false,
@@ -72,17 +72,23 @@ function isMedia(value: unknown): value is MediaItem[] {
72
72
  });
73
73
  }
74
74
 
75
- function normalizeThought(source: string, path: string, sha?: string): RepositoryThought | null {
75
+ function fileStem(filePath: string) {
76
+ return (filePath.split("/").pop() ?? "").replace(/\.md$/u, "");
77
+ }
78
+
79
+ function normalizeThought(source: string, filePath: string, sha?: string): RepositoryThought | null {
76
80
  const parsed = matter(source);
77
81
  const data = parsed.data;
78
- if (typeof data.title !== "string" || typeof data.slug !== "string" || typeof data.date !== "string") {
82
+ const slug = fileStem(filePath);
83
+ if (typeof data.title !== "string" || typeof data.date !== "string" || !slug) {
79
84
  return null;
80
85
  }
86
+ if (data.slug !== undefined && data.slug !== slug) return null;
81
87
  const media = isMedia(data.media) ? data.media : [];
82
88
  const status = data.draft === true ? "draft" : "published";
83
89
  return {
84
- id: data.slug,
85
- slug: data.slug,
90
+ id: slug,
91
+ slug,
86
92
  title: data.title,
87
93
  date: data.date,
88
94
  tags: isStringArray(data.tags) ? data.tags : [],
@@ -90,7 +96,7 @@ function normalizeThought(source: string, path: string, sha?: string): Repositor
90
96
  media,
91
97
  kind: "thought",
92
98
  draft: status === "draft",
93
- sourcePath: path,
99
+ sourcePath: filePath,
94
100
  content: parsed.content.trim(),
95
101
  status,
96
102
  sha,
@@ -108,7 +114,6 @@ function serializeThought(thought: {
108
114
  }) {
109
115
  return matter.stringify(thought.content.trim().concat("\n"), {
110
116
  title: thought.title,
111
- slug: thought.slug,
112
117
  date: thought.date ?? new Date().toISOString(),
113
118
  tags: thought.tags,
114
119
  mediaType: thought.media[0]?.type ?? "text",
@@ -227,7 +232,7 @@ export async function updateRepositoryPostPinned(input: {
227
232
  const existing = await readRepositoryFile(repositoryPath);
228
233
  if (!existing) throw new Error("未找到对应博文。");
229
234
  const parsed = matter(existing.source);
230
- if (parsed.data.slug !== input.slug) {
235
+ if (fileStem(repositoryPath) !== input.slug) {
231
236
  throw new Error("博文标识不匹配,已拒绝修改。");
232
237
  }
233
238
  const data = { ...parsed.data } as Record<string, unknown>;
@@ -37,9 +37,7 @@ export async function GET() {
37
37
  breaks: true,
38
38
  });
39
39
  const cover = entry.cover
40
- ? `<p><img src="${escapeXml(entry.cover)}" alt="${escapeXml(
41
- entry.coverAlt ?? entry.title,
42
- )}" /></p>`
40
+ ? `<p><img src="${escapeXml(entry.cover)}" alt="${escapeXml(entry.title)}" /></p>`
43
41
  : "";
44
42
  return `
45
43
  <item>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -71,12 +71,13 @@ const isExternalAsset = (value) =>
71
71
 
72
72
  const assetCache = new Map();
73
73
 
74
- function copyLocalAsset(value, markdownPath, sourcePath) {
74
+ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
75
75
  if (!value || isExternalAsset(value)) return value;
76
76
 
77
+ const baseDir = options.baseDir ?? path.dirname(markdownPath);
77
78
  const [assetPath, suffix = ""] = value.split(/(?=[?#])/u, 2);
78
79
  const decodedPath = decodeURIComponent(assetPath.replace(/^<|>$/g, ""));
79
- const absoluteSource = path.resolve(path.dirname(markdownPath), decodedPath);
80
+ const absoluteSource = path.resolve(baseDir, decodedPath);
80
81
  const relativeSource = path.relative(contentRoot, absoluteSource);
81
82
 
82
83
  if (
@@ -85,7 +86,7 @@ function copyLocalAsset(value, markdownPath, sourcePath) {
85
86
  !fs.existsSync(absoluteSource) ||
86
87
  !fs.statSync(absoluteSource).isFile()
87
88
  ) {
88
- fail(sourcePath, `找不到本地媒体文件 ${value}`);
89
+ fail(sourcePath, options.missingMessage ?? `找不到本地媒体文件 ${value}`);
89
90
  }
90
91
 
91
92
  if (!assetCache.has(absoluteSource)) {
@@ -254,9 +255,12 @@ function normalizeMedia(media, markdownPath, sourcePath) {
254
255
 
255
256
  function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
256
257
  const title = requireString(data, "title", sourcePath);
257
- const slug = optionalString(data, "slug", sourcePath) ?? path.basename(markdownPath, ".md");
258
+ const slug = path.basename(markdownPath, ".md");
258
259
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) {
259
- fail(sourcePath, "slug 只能包含小写字母、数字和连字符");
260
+ fail(sourcePath, "文件名只能包含小写字母、数字和连字符");
261
+ }
262
+ if (data.slug !== undefined) {
263
+ fail(sourcePath, "不要写 slug,文件名就是标识");
260
264
  }
261
265
 
262
266
  const date = normalizeDate(data.date, "date", sourcePath);
@@ -286,6 +290,7 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
286
290
 
287
291
  const publishTime = Date.parse(publishAt ?? date);
288
292
  if (!includeDrafts && (data.draft === true || publishTime > now)) return null;
293
+ delete data.coverAlt;
289
294
 
290
295
  return {
291
296
  ...data,
@@ -301,7 +306,10 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
301
306
  draft: Boolean(data.draft),
302
307
  sourcePath,
303
308
  cover: data.cover
304
- ? copyLocalAsset(data.cover, markdownPath, sourcePath)
309
+ ? copyLocalAsset(data.cover, markdownPath, sourcePath, {
310
+ baseDir: path.join(contentRoot, "covers"),
311
+ missingMessage: `找不到封面 ${data.cover};本地路径相对 content/covers`,
312
+ })
305
313
  : undefined,
306
314
  columnCover: data.columnCover
307
315
  ? copyLocalAsset(data.columnCover, markdownPath, sourcePath)
@@ -399,7 +407,7 @@ function readColumnReferences(posts) {
399
407
  if (seenColumns.has(slug)) fail(sourcePath, `专栏 slug ${slug} 重复。`);
400
408
  seenColumns.add(slug);
401
409
  if (!Array.isArray(value.posts) || !value.posts.every((entry) => typeof entry === "string")) {
402
- fail(sourcePath, "posts 必须是博文 slug 或相对路径数组。");
410
+ fail(sourcePath, "posts 必须是博文相对路径数组。");
403
411
  }
404
412
  const referenced = new Set();
405
413
  return value.posts.map((postReference, order) => {
@@ -487,7 +495,6 @@ export type ContentEntry = {
487
495
  columnCoverAlt?: string;
488
496
  order?: number;
489
497
  cover?: string;
490
- coverAlt?: string;
491
498
  mediaType?: "image" | "audio" | "video" | "link" | "text";
492
499
  media?: string | MediaItem[];
493
500
  mediaAlt?: string;
@@ -39,7 +39,6 @@ async function main() {
39
39
  const markdown = [
40
40
  "---",
41
41
  `title: ${JSON.stringify(title)}`,
42
- `slug: ${slug}`,
43
42
  `date: ${date}`,
44
43
  `description: ${JSON.stringify("请填写一句话摘要。")}`,
45
44
  `topic: ${JSON.stringify(topic)}`,