hd-garden 0.3.3 → 0.3.5

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.
@@ -4,6 +4,7 @@ import {
4
4
  listRepositoryThoughts,
5
5
  } from "../../../lib/github-content";
6
6
  import type { MediaItem } from "../../../lib/content";
7
+ import { formatAuthorDate } from "../../../../scripts/site-date.mjs";
7
8
 
8
9
  type ThoughtPayload = {
9
10
  title?: unknown;
@@ -108,7 +109,7 @@ export async function POST(request: Request) {
108
109
  tags: parsed.value.tags,
109
110
  media: parsed.value.media,
110
111
  status: parsed.value.status,
111
- date: new Date().toISOString(),
112
+ date: formatAuthorDate(),
112
113
  });
113
114
  return Response.json({ thought }, { status: 201 });
114
115
  } catch (error) {
@@ -9,6 +9,7 @@ import {
9
9
  postHref,
10
10
  posts,
11
11
  } from "../../lib/content";
12
+ import { ArticleReferences } from "../../components/ArticleReferences";
12
13
  import { MarkdownArticle } from "../../components/MarkdownArticle";
13
14
  import { TableOfContents } from "../../components/TableOfContents";
14
15
  import { Comments } from "../../components/Comments";
@@ -48,7 +49,7 @@ export async function generateMetadata({
48
49
  ? new Date(post.updated).toISOString()
49
50
  : undefined,
50
51
  tags: post.tags,
51
- images: post.cover ? [{ url: post.cover, alt: post.coverAlt }] : undefined,
52
+ images: post.cover ? [{ url: post.cover, alt: post.title }] : undefined,
52
53
  },
53
54
  twitter: {
54
55
  card: "summary_large_image",
@@ -92,7 +93,7 @@ export default async function PostPage({
92
93
  <img
93
94
  className="u-photo"
94
95
  src={post.cover}
95
- alt={post.coverAlt ?? `${post.title} 封面`}
96
+ alt={`${post.title} 封面`}
96
97
  />
97
98
  </figure>
98
99
  )}
@@ -114,6 +115,7 @@ export default async function PostPage({
114
115
  <div className="e-content">
115
116
  <MarkdownArticle content={post.content} />
116
117
  </div>
118
+ <ArticleReferences references={post.references} />
117
119
  <div className="article-tags">
118
120
  {post.tags?.map((tag) => (
119
121
  <span className="p-category" key={tag}>
@@ -10,6 +10,7 @@ import {
10
10
  columnHref,
11
11
  columnEntryPath,
12
12
  } from "../../../lib/content";
13
+ import { ArticleReferences } from "../../../components/ArticleReferences";
13
14
  import { MarkdownArticle } from "../../../components/MarkdownArticle";
14
15
  import { TableOfContents } from "../../../components/TableOfContents";
15
16
  import { Comments } from "../../../components/Comments";
@@ -52,7 +53,7 @@ export async function generateMetadata({
52
53
  ? new Date(entry.updated).toISOString()
53
54
  : undefined,
54
55
  tags: entry.tags,
55
- images: entry.cover ? [{ url: entry.cover, alt: entry.coverAlt }] : undefined,
56
+ images: entry.cover ? [{ url: entry.cover, alt: entry.title }] : undefined,
56
57
  },
57
58
  twitter: {
58
59
  card: "summary_large_image",
@@ -115,7 +116,7 @@ export default async function ColumnEntryPage({
115
116
  <img
116
117
  className="u-photo"
117
118
  src={entry.cover}
118
- alt={entry.coverAlt ?? `${entry.title} 封面`}
119
+ alt={`${entry.title} 封面`}
119
120
  />
120
121
  </figure>
121
122
  )}
@@ -137,6 +138,7 @@ export default async function ColumnEntryPage({
137
138
  <div className="e-content">
138
139
  <MarkdownArticle content={entry.content} />
139
140
  </div>
141
+ <ArticleReferences references={entry.references} />
140
142
  {next && (
141
143
  <Link
142
144
  href={columnHref(next)}
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
+ import { formatAuthorDate } from "../../scripts/site-date.mjs";
4
5
  import { useMemo, useState } from "react";
5
6
  import {
6
7
  ArrowSquareOut,
@@ -239,7 +240,7 @@ export function AdminDashboard({
239
240
  setThoughts((current) =>
240
241
  current.map((thought) =>
241
242
  thought.id === id
242
- ? { ...thought, status, date: status === "published" ? new Date().toISOString() : thought.date }
243
+ ? { ...thought, status, date: status === "published" ? formatAuthorDate() : thought.date }
243
244
  : thought,
244
245
  ),
245
246
  );
@@ -0,0 +1,47 @@
1
+ type ReferenceItem = {
2
+ title: string;
3
+ url?: string;
4
+ };
5
+
6
+ export function ArticleReferences({
7
+ references,
8
+ }: {
9
+ references?: ReferenceItem[];
10
+ }) {
11
+ if (!references?.length) return null;
12
+
13
+ return (
14
+ <section className="article-references" aria-labelledby="article-references-heading">
15
+ <p id="article-references-heading">参考</p>
16
+ <table>
17
+ <tbody>
18
+ {references.map((item, index) => {
19
+ const number = index + 1;
20
+ return (
21
+ <tr key={index}>
22
+ <td>
23
+ {item.url ? (
24
+ <a href={item.url} tabIndex={-1} aria-hidden="true">
25
+ {number}
26
+ </a>
27
+ ) : (
28
+ <span>{number}</span>
29
+ )}
30
+ </td>
31
+ <td>
32
+ {item.url ? (
33
+ <a href={item.url} rel="noreferrer">
34
+ <span>{item.title}</span>
35
+ </a>
36
+ ) : (
37
+ <span>{item.title}</span>
38
+ )}
39
+ </td>
40
+ </tr>
41
+ );
42
+ })}
43
+ </tbody>
44
+ </table>
45
+ </section>
46
+ );
47
+ }
@@ -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
- date: 2026-08-03T09:00:00+08:00
29
+ date: 2026-08-03 09:00: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,8 +42,7 @@ cover: ./assets/cover.jpg
43
42
 
44
43
  const thoughtSource = `---
45
44
  title: 雨后的散步
46
- slug: walk-after-rain
47
- date: 2026-08-03T18:30:00+08:00
45
+ date: 2026-08-03 18:30:00
48
46
  tags: [生活]
49
47
  media:
50
48
  - type: image
@@ -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
  </>,
@@ -20,6 +20,7 @@ declare module "@garden/generated-content" {
20
20
  draft?: boolean;
21
21
  topic?: string;
22
22
  tags?: string[];
23
+ references?: { title: string; url?: string }[];
23
24
  pinned?: boolean;
24
25
  readingTime?: string;
25
26
  kind: "post" | "column" | "thought";
@@ -33,7 +34,6 @@ declare module "@garden/generated-content" {
33
34
  columnCoverAlt?: string;
34
35
  order?: number;
35
36
  cover?: string;
36
- coverAlt?: string;
37
37
  mediaType?: "image" | "audio" | "video" | "link" | "text";
38
38
  media?: string | MediaItem[];
39
39
  mediaAlt?: string;
package/app/globals.css CHANGED
@@ -2802,6 +2802,72 @@ button {
2802
2802
  color: #7f8790;
2803
2803
  }
2804
2804
 
2805
+ .article-references {
2806
+ margin-top: 64px;
2807
+ }
2808
+
2809
+ .article-references > p {
2810
+ margin: 0 0 14px;
2811
+ color: var(--accent);
2812
+ font-size: 12px;
2813
+ font-weight: 700;
2814
+ }
2815
+
2816
+ .article-references table {
2817
+ width: 100%;
2818
+ border-collapse: collapse;
2819
+ font-size: 12px;
2820
+ }
2821
+
2822
+ .article-references td {
2823
+ padding: 0;
2824
+ border: 1px solid var(--line);
2825
+ color: var(--ink-soft);
2826
+ text-align: left;
2827
+ overflow-wrap: anywhere;
2828
+ }
2829
+
2830
+ .article-references td:first-child {
2831
+ width: 1%;
2832
+ color: var(--accent);
2833
+ font-variant-numeric: tabular-nums;
2834
+ text-align: right;
2835
+ white-space: nowrap;
2836
+ }
2837
+
2838
+ .article-references td a,
2839
+ .article-references td > span {
2840
+ display: block;
2841
+ padding: 11px 13px;
2842
+ }
2843
+
2844
+ .article-references td:first-child a,
2845
+ .article-references td:first-child > span {
2846
+ padding-right: 8px;
2847
+ color: var(--accent);
2848
+ text-decoration: none;
2849
+ }
2850
+
2851
+ .article-references td a {
2852
+ color: inherit;
2853
+ text-decoration: none;
2854
+ }
2855
+
2856
+ .article-references td a span {
2857
+ text-decoration: underline;
2858
+ text-decoration-color: var(--accent);
2859
+ text-underline-offset: 4px;
2860
+ }
2861
+
2862
+ .article-references td a:hover span {
2863
+ color: var(--accent);
2864
+ }
2865
+
2866
+ .article-references + .article-tags,
2867
+ .article-references + .next-entry {
2868
+ margin-top: 36px;
2869
+ }
2870
+
2805
2871
  .article-tags {
2806
2872
  display: flex;
2807
2873
  flex-wrap: wrap;
@@ -7,6 +7,7 @@ import {
7
7
  type MediaItem,
8
8
  } from "@garden/generated-content";
9
9
  import GithubSlugger from "github-slugger";
10
+ import { canonicalSiteDate } from "../../scripts/site-date.mjs";
10
11
  import { toString } from "mdast-util-to-string";
11
12
  import remarkParse from "remark-parse";
12
13
  import { unified } from "unified";
@@ -23,13 +24,18 @@ export const columnEntryPath = (entry: Pick<ContentEntry, "column" | "path">) =>
23
24
  export const columnHref = (entry: Pick<ContentEntry, "column" | "path">) =>
24
25
  `/columns/${entry.column}/${columnEntryPath(entry)}`;
25
26
 
26
- export const formatDate = (value: string, withTime = false) =>
27
- new Intl.DateTimeFormat("zh-CN", {
27
+ export const formatDate = (value: string, withTime = false) => {
28
+ const canonical = canonicalSiteDate(value) ?? value;
29
+ const date = new Date(canonical);
30
+ if (Number.isNaN(date.getTime())) return value;
31
+ return new Intl.DateTimeFormat("zh-CN", {
32
+ timeZone: "Asia/Shanghai",
28
33
  year: "numeric",
29
34
  month: "2-digit",
30
35
  day: "2-digit",
31
36
  ...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
32
- }).format(new Date(value));
37
+ }).format(date);
38
+ };
33
39
 
34
40
  /**
35
41
  * Parses the same Markdown heading text that ReactMarkdown sends to
@@ -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,
@@ -1,6 +1,16 @@
1
1
  import matter from "gray-matter";
2
+ import { parse as parseYaml } from "yaml";
2
3
  import type { ContentEntry, MediaItem } from "./content";
3
4
  import { repositoryPostPath } from "./repository-path.mjs";
5
+ import { canonicalSiteDate, formatAuthorDate } from "../../scripts/site-date.mjs";
6
+
7
+ const matterOptions = {
8
+ engines: {
9
+ yaml: {
10
+ parse: (input: string) => parseYaml(input) ?? {},
11
+ },
12
+ },
13
+ };
4
14
 
5
15
  const apiBase = "https://api.github.com";
6
16
 
@@ -72,17 +82,23 @@ function isMedia(value: unknown): value is MediaItem[] {
72
82
  });
73
83
  }
74
84
 
75
- function normalizeThought(source: string, path: string, sha?: string): RepositoryThought | null {
76
- const parsed = matter(source);
85
+ function fileStem(filePath: string) {
86
+ return (filePath.split("/").pop() ?? "").replace(/\.md$/u, "");
87
+ }
88
+
89
+ function normalizeThought(source: string, filePath: string, sha?: string): RepositoryThought | null {
90
+ const parsed = matter(source, matterOptions);
77
91
  const data = parsed.data;
78
- if (typeof data.title !== "string" || typeof data.slug !== "string" || typeof data.date !== "string") {
92
+ const slug = fileStem(filePath);
93
+ if (typeof data.title !== "string" || typeof data.date !== "string" || !canonicalSiteDate(data.date) || !slug) {
79
94
  return null;
80
95
  }
96
+ if (data.slug !== undefined && data.slug !== slug) return null;
81
97
  const media = isMedia(data.media) ? data.media : [];
82
98
  const status = data.draft === true ? "draft" : "published";
83
99
  return {
84
- id: data.slug,
85
- slug: data.slug,
100
+ id: slug,
101
+ slug,
86
102
  title: data.title,
87
103
  date: data.date,
88
104
  tags: isStringArray(data.tags) ? data.tags : [],
@@ -90,7 +106,7 @@ function normalizeThought(source: string, path: string, sha?: string): Repositor
90
106
  media,
91
107
  kind: "thought",
92
108
  draft: status === "draft",
93
- sourcePath: path,
109
+ sourcePath: filePath,
94
110
  content: parsed.content.trim(),
95
111
  status,
96
112
  sha,
@@ -108,8 +124,7 @@ function serializeThought(thought: {
108
124
  }) {
109
125
  return matter.stringify(thought.content.trim().concat("\n"), {
110
126
  title: thought.title,
111
- slug: thought.slug,
112
- date: thought.date ?? new Date().toISOString(),
127
+ date: thought.date ?? formatAuthorDate(),
113
128
  tags: thought.tags,
114
129
  mediaType: thought.media[0]?.type ?? "text",
115
130
  media: thought.media,
@@ -173,7 +188,11 @@ export async function listRepositoryThoughts() {
173
188
  );
174
189
  return thoughts
175
190
  .filter((thought): thought is RepositoryThought => Boolean(thought))
176
- .sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
191
+ .sort((left, right) => {
192
+ const rightTime = Date.parse(canonicalSiteDate(right.date) ?? "");
193
+ const leftTime = Date.parse(canonicalSiteDate(left.date) ?? "");
194
+ return rightTime - leftTime;
195
+ });
177
196
  }
178
197
 
179
198
  export async function createRepositoryThought(input: Omit<RepositoryThought, "id" | "kind" | "sourcePath" | "draft" | "sha" | "mediaType">) {
@@ -196,7 +215,7 @@ export async function updateRepositoryThought(slug: string, status: "draft" | "p
196
215
  if (!existing) throw new Error("未找到对应随想。");
197
216
  const thought = normalizeThought(existing.source, path, existing.sha);
198
217
  if (!thought) throw new Error("随想内容格式不正确。");
199
- const source = serializeThought({ ...thought, status, date: status === "published" ? new Date().toISOString() : thought.date });
218
+ const source = serializeThought({ ...thought, status, date: status === "published" ? formatAuthorDate() : thought.date });
200
219
  const { response } = await mutateRepository(path, "PUT", {
201
220
  message: `content: ${status === "published" ? "发布" : "撤回"}随想 ${thought.title}`,
202
221
  content: utf8ToBase64(source),
@@ -227,7 +246,7 @@ export async function updateRepositoryPostPinned(input: {
227
246
  const existing = await readRepositoryFile(repositoryPath);
228
247
  if (!existing) throw new Error("未找到对应博文。");
229
248
  const parsed = matter(existing.source);
230
- if (parsed.data.slug !== input.slug) {
249
+ if (fileStem(repositoryPath) !== input.slug) {
231
250
  throw new Error("博文标识不匹配,已拒绝修改。");
232
251
  }
233
252
  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.5",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import matter from "gray-matter";
5
5
  import { marked } from "marked";
6
6
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
+ import { canonicalSiteDate } from "./site-date.mjs";
7
8
 
8
9
  function resolveSiteRoot() {
9
10
  if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
@@ -52,15 +53,47 @@ const optionalString = (data, key, sourcePath) => {
52
53
  };
53
54
 
54
55
  const normalizeDate = (value, key, sourcePath) => {
55
- const normalized = value instanceof Date ? value.toISOString() : value;
56
- if (
57
- typeof normalized !== "string" ||
58
- !normalized ||
59
- Number.isNaN(Date.parse(normalized))
60
- ) {
61
- fail(sourcePath, `${key} 必须是有效日期`);
56
+ const canonical = canonicalSiteDate(value);
57
+ if (!canonical) {
58
+ fail(sourcePath, `${key} 必须是有效日期,例如 2026-09-18 14:39:00`);
62
59
  }
63
- return normalized;
60
+ return canonical;
61
+ };
62
+
63
+ const normalizeReferences = (value, sourcePath) => {
64
+ if (value === undefined) return undefined;
65
+ if (!Array.isArray(value)) fail(sourcePath, "references 必须是数组");
66
+ const references = value.map((item, index) => {
67
+ const where = `references[${index}]`;
68
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
69
+ fail(sourcePath, `${where} 必须是包含 title 的对象`);
70
+ }
71
+ for (const key of Object.keys(item)) {
72
+ if (key !== "title" && key !== "url") {
73
+ fail(sourcePath, `${where} 不支持 ${key}`);
74
+ }
75
+ }
76
+ if (typeof item.title !== "string" || !item.title.trim()) {
77
+ fail(sourcePath, `${where} 缺少有效的 title`);
78
+ }
79
+ const title = item.title.trim();
80
+ if (item.url === undefined || item.url === null || item.url === "") {
81
+ return { title };
82
+ }
83
+ if (typeof item.url !== "string") fail(sourcePath, `${where}.url 必须是字符串`);
84
+ const url = item.url.trim();
85
+ let parsed;
86
+ try {
87
+ parsed = new URL(url);
88
+ } catch {
89
+ fail(sourcePath, `${where}.url 必须是 http 或 https 链接`);
90
+ }
91
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
92
+ fail(sourcePath, `${where}.url 必须是 http 或 https 链接`);
93
+ }
94
+ return { title, url };
95
+ });
96
+ return references.length ? references : undefined;
64
97
  };
65
98
 
66
99
  const isExternalAsset = (value) =>
@@ -71,12 +104,13 @@ const isExternalAsset = (value) =>
71
104
 
72
105
  const assetCache = new Map();
73
106
 
74
- function copyLocalAsset(value, markdownPath, sourcePath) {
107
+ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
75
108
  if (!value || isExternalAsset(value)) return value;
76
109
 
110
+ const baseDir = options.baseDir ?? path.dirname(markdownPath);
77
111
  const [assetPath, suffix = ""] = value.split(/(?=[?#])/u, 2);
78
112
  const decodedPath = decodeURIComponent(assetPath.replace(/^<|>$/g, ""));
79
- const absoluteSource = path.resolve(path.dirname(markdownPath), decodedPath);
113
+ const absoluteSource = path.resolve(baseDir, decodedPath);
80
114
  const relativeSource = path.relative(contentRoot, absoluteSource);
81
115
 
82
116
  if (
@@ -85,7 +119,7 @@ function copyLocalAsset(value, markdownPath, sourcePath) {
85
119
  !fs.existsSync(absoluteSource) ||
86
120
  !fs.statSync(absoluteSource).isFile()
87
121
  ) {
88
- fail(sourcePath, `找不到本地媒体文件 ${value}`);
122
+ fail(sourcePath, options.missingMessage ?? `找不到本地媒体文件 ${value}`);
89
123
  }
90
124
 
91
125
  if (!assetCache.has(absoluteSource)) {
@@ -254,9 +288,12 @@ function normalizeMedia(media, markdownPath, sourcePath) {
254
288
 
255
289
  function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
256
290
  const title = requireString(data, "title", sourcePath);
257
- const slug = optionalString(data, "slug", sourcePath) ?? path.basename(markdownPath, ".md");
291
+ const slug = path.basename(markdownPath, ".md");
258
292
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) {
259
- fail(sourcePath, "slug 只能包含小写字母、数字和连字符");
293
+ fail(sourcePath, "文件名只能包含小写字母、数字和连字符");
294
+ }
295
+ if (data.slug !== undefined) {
296
+ fail(sourcePath, "不要写 slug,文件名就是标识");
260
297
  }
261
298
 
262
299
  const date = normalizeDate(data.date, "date", sourcePath);
@@ -286,9 +323,13 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
286
323
 
287
324
  const publishTime = Date.parse(publishAt ?? date);
288
325
  if (!includeDrafts && (data.draft === true || publishTime > now)) return null;
326
+ delete data.coverAlt;
327
+ const references = normalizeReferences(data.references, sourcePath);
328
+ delete data.references;
289
329
 
290
330
  return {
291
331
  ...data,
332
+ references,
292
333
  title,
293
334
  slug,
294
335
  path: kind === "post"
@@ -301,7 +342,10 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
301
342
  draft: Boolean(data.draft),
302
343
  sourcePath,
303
344
  cover: data.cover
304
- ? copyLocalAsset(data.cover, markdownPath, sourcePath)
345
+ ? copyLocalAsset(data.cover, markdownPath, sourcePath, {
346
+ baseDir: path.join(contentRoot, "covers"),
347
+ missingMessage: `找不到封面 ${data.cover};本地路径相对 content/covers`,
348
+ })
305
349
  : undefined,
306
350
  columnCover: data.columnCover
307
351
  ? copyLocalAsset(data.columnCover, markdownPath, sourcePath)
@@ -327,7 +371,13 @@ function readMarkdownTree(directory, kind) {
327
371
  if (!entry.name.endsWith(".md")) return [];
328
372
 
329
373
  const raw = fs.readFileSync(fullPath, "utf8");
330
- const parsed = matter(raw);
374
+ const parsed = matter(raw, {
375
+ engines: {
376
+ yaml: {
377
+ parse: (input) => parseYaml(input) ?? {},
378
+ },
379
+ },
380
+ });
331
381
  const sourcePath = path.relative(contentRoot, fullPath);
332
382
  const normalized = validateAndNormalize(
333
383
  parsed.data,
@@ -399,7 +449,7 @@ function readColumnReferences(posts) {
399
449
  if (seenColumns.has(slug)) fail(sourcePath, `专栏 slug ${slug} 重复。`);
400
450
  seenColumns.add(slug);
401
451
  if (!Array.isArray(value.posts) || !value.posts.every((entry) => typeof entry === "string")) {
402
- fail(sourcePath, "posts 必须是博文 slug 或相对路径数组。");
452
+ fail(sourcePath, "posts 必须是博文相对路径数组。");
403
453
  }
404
454
  const referenced = new Set();
405
455
  return value.posts.map((postReference, order) => {
@@ -474,6 +524,7 @@ export type ContentEntry = {
474
524
  draft?: boolean;
475
525
  topic?: string;
476
526
  tags?: string[];
527
+ references?: { title: string; url?: string }[];
477
528
  pinned?: boolean;
478
529
  readingTime?: string;
479
530
  kind: "post" | "column" | "thought";
@@ -487,7 +538,6 @@ export type ContentEntry = {
487
538
  columnCoverAlt?: string;
488
539
  order?: number;
489
540
  cover?: string;
490
- coverAlt?: string;
491
541
  mediaType?: "image" | "audio" | "video" | "link" | "text";
492
542
  media?: string | MediaItem[];
493
543
  mediaAlt?: string;
@@ -1,13 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstat, mkdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { formatAuthorDate } from "./site-date.mjs";
4
5
 
5
6
  const root = path.resolve(process.env.GARDEN_SITE || process.cwd());
6
7
  const option = (name) => (process.env[`GARDEN_NEW_${name}`] ?? "").trim();
7
8
 
8
9
  async function main() {
9
10
  const title = option("TITLE") || "新文章";
10
- const date = new Date().toISOString();
11
+ const date = formatAuthorDate();
11
12
  const titleSlug = title.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "");
12
13
  const slug = option("SLUG") || titleSlug || `post-${date.slice(0, 10)}-${randomUUID().slice(0, 8)}`;
13
14
  const directory = option("DIR");
@@ -39,7 +40,6 @@ async function main() {
39
40
  const markdown = [
40
41
  "---",
41
42
  `title: ${JSON.stringify(title)}`,
42
- `slug: ${slug}`,
43
43
  `date: ${date}`,
44
44
  `description: ${JSON.stringify("请填写一句话摘要。")}`,
45
45
  `topic: ${JSON.stringify(topic)}`,
@@ -0,0 +1,59 @@
1
+ const siteTimeZone = "Asia/Shanghai";
2
+ const siteTimeOffset = "+08:00";
3
+ const civilDatePattern =
4
+ /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?$/;
5
+
6
+ const clockInShanghai = (date) => {
7
+ const parts = new Intl.DateTimeFormat("en-CA", {
8
+ timeZone: siteTimeZone,
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit",
12
+ hour: "2-digit",
13
+ minute: "2-digit",
14
+ second: "2-digit",
15
+ hourCycle: "h23",
16
+ }).formatToParts(date);
17
+ const pick = (type) => parts.find((part) => part.type === type)?.value ?? "";
18
+ return {
19
+ year: pick("year"),
20
+ month: pick("month"),
21
+ day: pick("day"),
22
+ hour: pick("hour"),
23
+ minute: pick("minute"),
24
+ second: pick("second"),
25
+ };
26
+ };
27
+
28
+ export function formatAuthorDate(now = new Date()) {
29
+ const clock = clockInShanghai(now);
30
+ return `${clock.year}-${clock.month}-${clock.day} ${clock.hour}:${clock.minute}:${clock.second}`;
31
+ }
32
+
33
+ export function canonicalSiteDate(value) {
34
+ if (value instanceof Date) {
35
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
36
+ }
37
+ if (typeof value !== "string") return null;
38
+ const text = value.trim();
39
+ const match = civilDatePattern.exec(text);
40
+ if (match) {
41
+ const [, year, month, day, hour = "00", minute = "00", second = "00"] = match;
42
+ const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}${siteTimeOffset}`;
43
+ const parsed = new Date(iso);
44
+ if (Number.isNaN(parsed.getTime())) return null;
45
+ const clock = clockInShanghai(parsed);
46
+ if (
47
+ clock.year !== year ||
48
+ clock.month !== month ||
49
+ clock.day !== day ||
50
+ clock.hour !== hour ||
51
+ clock.minute !== minute ||
52
+ clock.second !== second
53
+ ) {
54
+ return null;
55
+ }
56
+ return iso;
57
+ }
58
+ return Number.isNaN(Date.parse(text)) ? null : text;
59
+ }