hd-garden 0.3.4 → 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";
@@ -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";
@@ -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
+ }
@@ -26,7 +26,7 @@ function SourcePanel({ path, source, language }: SourcePanelProps) {
26
26
 
27
27
  const postSource = `---
28
28
  title: 我的第一篇文章
29
- date: 2026-08-03T09:00:00+08:00
29
+ date: 2026-08-03 09:00:00
30
30
  description: 这篇文章记录我为什么重新开始写博客。
31
31
  topic: 写作
32
32
  tags: [Markdown, 博客]
@@ -42,7 +42,7 @@ cover: writing/first-post.jpg
42
42
 
43
43
  const thoughtSource = `---
44
44
  title: 雨后的散步
45
- date: 2026-08-03T18:30:00+08:00
45
+ date: 2026-08-03 18:30:00
46
46
  tags: [生活]
47
47
  media:
48
48
  - type: image
@@ -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";
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,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
 
@@ -77,10 +87,10 @@ function fileStem(filePath: string) {
77
87
  }
78
88
 
79
89
  function normalizeThought(source: string, filePath: string, sha?: string): RepositoryThought | null {
80
- const parsed = matter(source);
90
+ const parsed = matter(source, matterOptions);
81
91
  const data = parsed.data;
82
92
  const slug = fileStem(filePath);
83
- if (typeof data.title !== "string" || typeof data.date !== "string" || !slug) {
93
+ if (typeof data.title !== "string" || typeof data.date !== "string" || !canonicalSiteDate(data.date) || !slug) {
84
94
  return null;
85
95
  }
86
96
  if (data.slug !== undefined && data.slug !== slug) return null;
@@ -114,7 +124,7 @@ function serializeThought(thought: {
114
124
  }) {
115
125
  return matter.stringify(thought.content.trim().concat("\n"), {
116
126
  title: thought.title,
117
- date: thought.date ?? new Date().toISOString(),
127
+ date: thought.date ?? formatAuthorDate(),
118
128
  tags: thought.tags,
119
129
  mediaType: thought.media[0]?.type ?? "text",
120
130
  media: thought.media,
@@ -178,7 +188,11 @@ export async function listRepositoryThoughts() {
178
188
  );
179
189
  return thoughts
180
190
  .filter((thought): thought is RepositoryThought => Boolean(thought))
181
- .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
+ });
182
196
  }
183
197
 
184
198
  export async function createRepositoryThought(input: Omit<RepositoryThought, "id" | "kind" | "sourcePath" | "draft" | "sha" | "mediaType">) {
@@ -201,7 +215,7 @@ export async function updateRepositoryThought(slug: string, status: "draft" | "p
201
215
  if (!existing) throw new Error("未找到对应随想。");
202
216
  const thought = normalizeThought(existing.source, path, existing.sha);
203
217
  if (!thought) throw new Error("随想内容格式不正确。");
204
- 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 });
205
219
  const { response } = await mutateRepository(path, "PUT", {
206
220
  message: `content: ${status === "published" ? "发布" : "撤回"}随想 ${thought.title}`,
207
221
  content: utf8ToBase64(source),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.3.4",
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) =>
@@ -291,9 +324,12 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
291
324
  const publishTime = Date.parse(publishAt ?? date);
292
325
  if (!includeDrafts && (data.draft === true || publishTime > now)) return null;
293
326
  delete data.coverAlt;
327
+ const references = normalizeReferences(data.references, sourcePath);
328
+ delete data.references;
294
329
 
295
330
  return {
296
331
  ...data,
332
+ references,
297
333
  title,
298
334
  slug,
299
335
  path: kind === "post"
@@ -335,7 +371,13 @@ function readMarkdownTree(directory, kind) {
335
371
  if (!entry.name.endsWith(".md")) return [];
336
372
 
337
373
  const raw = fs.readFileSync(fullPath, "utf8");
338
- const parsed = matter(raw);
374
+ const parsed = matter(raw, {
375
+ engines: {
376
+ yaml: {
377
+ parse: (input) => parseYaml(input) ?? {},
378
+ },
379
+ },
380
+ });
339
381
  const sourcePath = path.relative(contentRoot, fullPath);
340
382
  const normalized = validateAndNormalize(
341
383
  parsed.data,
@@ -482,6 +524,7 @@ export type ContentEntry = {
482
524
  draft?: boolean;
483
525
  topic?: string;
484
526
  tags?: string[];
527
+ references?: { title: string; url?: string }[];
485
528
  pinned?: boolean;
486
529
  readingTime?: string;
487
530
  kind: "post" | "column" | "thought";
@@ -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");
@@ -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
+ }