hd-garden 0.3.2 → 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.
@@ -19,8 +19,8 @@ const focus = about.focus ?? [
19
19
  const cta = about.cta ?? { href: "/blog", label: "从博文开始" };
20
20
 
21
21
  export const metadata = {
22
- title: about.title,
23
- description: about.subtitle,
22
+ title: { absolute: siteConfig.name },
23
+ description: about.subtitle || siteConfig.description,
24
24
  };
25
25
 
26
26
  export default function AboutPage() {
@@ -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
  )}
package/app/blog/page.tsx CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Suspense } from "react";
2
- import { siteConfig } from "../site.config";
2
+ import { siteConfig, siteDocumentTitle } from "../site.config";
3
3
  import { BlogArchive } from "../components/BlogArchive";
4
4
 
5
5
  export const metadata = {
6
- title: siteConfig.pages.blog.title,
7
- description: siteConfig.pages.blog.subtitle,
6
+ title: siteDocumentTitle,
7
+ description: siteConfig.pages.blog.subtitle || siteConfig.description,
8
8
  };
9
9
 
10
10
  export default function BlogIndex() {
@@ -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
  )}
@@ -1,13 +1,14 @@
1
+ /* eslint-disable @next/next/no-img-element -- static GitHub Pages assets must keep their exact paths. */
1
2
  import Link from "next/link";
2
3
  import { ArrowRight } from "@phosphor-icons/react/ssr";
3
4
  import { columnHref, columns } from "../lib/content";
4
5
  import { PageIntro } from "../components/PageIntro";
5
6
  import { ContentEmptyState } from "../components/ContentEmptyState";
6
- import { siteConfig } from "../site.config";
7
+ import { siteConfig, siteDocumentTitle } from "../site.config";
7
8
 
8
9
  export const metadata = {
9
- title: siteConfig.pages.columns.title,
10
- description: siteConfig.pages.columns.subtitle,
10
+ title: siteDocumentTitle,
11
+ description: siteConfig.pages.columns.subtitle || siteConfig.description,
11
12
  };
12
13
 
13
14
  export default function ColumnsPage() {
@@ -33,35 +34,45 @@ export default function ColumnsPage() {
33
34
  />
34
35
 
35
36
  {grouped.length > 0 ? <div className="column-feature-list">
36
- {grouped.map(({ slug, entries, first }, index) => (
37
- <Link
38
- href={columnHref(first)}
39
- className="column-feature"
40
- key={slug}
41
- >
42
- <div className="column-feature-copy">
43
- <span>
44
- {first.columnStatus ?? "持续更新"} · {entries.length} 篇 ·{" "}
45
- {first.topic ?? "主题写作"}
46
- </span>
47
- <h2>{first.columnTitle}</h2>
48
- <p>
49
- {first.columnDescription ??
50
- first.description ??
51
- "沿着一条清晰的阅读路径,把一个主题持续写深。"}
52
- </p>
53
- <strong>
54
- 从第一篇开始
55
- <ArrowRight size={17} />
56
- </strong>
57
- </div>
58
- <div className="column-feature-index">
59
- <span>主题专栏</span>
60
- <strong>{String(index + 1).padStart(2, "0")}</strong>
61
- <small>{slug.replaceAll("-", " ").toUpperCase()}</small>
62
- </div>
63
- </Link>
64
- ))}
37
+ {grouped.map(({ slug, entries, first }, index) => {
38
+ const cover = first.columnCover
39
+ ? { src: first.columnCover, alt: first.columnCoverAlt ?? `${first.columnTitle} 专栏封面` }
40
+ : undefined;
41
+ return (
42
+ <Link
43
+ href={columnHref(first)}
44
+ className="column-feature"
45
+ key={slug}
46
+ >
47
+ <div className="column-feature-copy">
48
+ <span>
49
+ {first.columnStatus ?? "持续更新"} · {entries.length} 篇 ·{" "}
50
+ {first.topic ?? "主题写作"}
51
+ </span>
52
+ <h2>{first.columnTitle}</h2>
53
+ <p>
54
+ {first.columnDescription ??
55
+ first.description ??
56
+ "沿着一条清晰的阅读路径,把一个主题持续写深。"}
57
+ </p>
58
+ <strong>
59
+ 从第一篇开始
60
+ <ArrowRight size={17} />
61
+ </strong>
62
+ </div>
63
+ <div className="column-feature-index">
64
+ {cover && (
65
+ <img className="column-feature-cover" src={cover.src} alt={cover.alt} />
66
+ )}
67
+ <div className="column-feature-index-label">
68
+ <span>主题专栏</span>
69
+ <strong>{String(index + 1).padStart(2, "0")}</strong>
70
+ <small>{slug.replaceAll("-", " ").toUpperCase()}</small>
71
+ </div>
72
+ </div>
73
+ </Link>
74
+ );
75
+ })}
65
76
  </div> : <ContentEmptyState kind="columns" />}
66
77
  </main>
67
78
  );
@@ -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
  </>,
@@ -11,6 +11,7 @@ import rehypeSlug from "rehype-slug";
11
11
  import rehypeAutolinkHeadings from "rehype-autolink-headings";
12
12
  import rehypeHighlight from "rehype-highlight";
13
13
  import { MermaidDiagram } from "./MermaidDiagram";
14
+ import { MarkdownImage } from "./MarkdownImage";
14
15
  import { ExternalEmbed } from "./ExternalEmbed";
15
16
  import { WidgetChart } from "./WidgetChart";
16
17
 
@@ -106,11 +107,7 @@ export function MarkdownArticle({ content }: { content: string }) {
106
107
  </a>
107
108
  );
108
109
  },
109
- img: ({ alt, ...props }) => (
110
- // Native images keep Typora-authored relative paths intact.
111
- // eslint-disable-next-line @next/next/no-img-element
112
- <img loading="lazy" alt={alt ?? ""} {...props} />
113
- ),
110
+ img: MarkdownImage,
114
111
  code: CodeBlock,
115
112
  pre: ({ children, ...props }) => {
116
113
  const child = Children.only(children);
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
4
+
5
+ type MarkdownImageProps = ComponentPropsWithoutRef<"img">;
6
+
7
+ export function MarkdownImage({ alt = "", src, ...props }: MarkdownImageProps) {
8
+ const [open, setOpen] = useState(false);
9
+ const description = alt || "查看原图";
10
+
11
+ useEffect(() => {
12
+ if (!open) return undefined;
13
+
14
+ const closeOnEscape = (event: KeyboardEvent) => {
15
+ if (event.key === "Escape") setOpen(false);
16
+ };
17
+ document.addEventListener("keydown", closeOnEscape);
18
+ document.body.classList.add("image-preview-open");
19
+
20
+ return () => {
21
+ document.removeEventListener("keydown", closeOnEscape);
22
+ document.body.classList.remove("image-preview-open");
23
+ };
24
+ }, [open]);
25
+
26
+ if (!src) return null;
27
+
28
+ return (
29
+ <>
30
+ <button
31
+ type="button"
32
+ className="markdown-image-trigger"
33
+ onClick={() => setOpen(true)}
34
+ aria-label={`放大查看:${description}`}
35
+ >
36
+ {/* Native images preserve Typora-authored relative paths. */}
37
+ {/* eslint-disable-next-line @next/next/no-img-element */}
38
+ <img loading="lazy" alt={alt} src={src} {...props} />
39
+ </button>
40
+
41
+ {open ? (
42
+ <div
43
+ className="markdown-image-lightbox"
44
+ role="dialog"
45
+ aria-modal="true"
46
+ aria-label={description}
47
+ onClick={() => setOpen(false)}
48
+ >
49
+ <div className="markdown-image-lightbox-content" onClick={(event) => event.stopPropagation()}>
50
+ {/* eslint-disable-next-line @next/next/no-img-element */}
51
+ <img src={src} alt={alt} {...props} />
52
+ <button
53
+ type="button"
54
+ className="markdown-image-lightbox-close"
55
+ onClick={() => setOpen(false)}
56
+ aria-label="关闭图片预览"
57
+ >
58
+ 关闭
59
+ </button>
60
+ </div>
61
+ </div>
62
+ ) : null}
63
+ </>
64
+ );
65
+ }
@@ -7,7 +7,7 @@ import { TableOfContents } from "../components/TableOfContents";
7
7
  import { siteConfig } from "../site.config";
8
8
 
9
9
  export const metadata = {
10
- title: `Garden · ${siteConfig.pages.garden.title}`,
10
+ title: "Garden",
11
11
  description:
12
12
  "一个以 Markdown 为内容源、通过 GitHub Pages 发布的个人博客框架,支持博文、随想、专栏、搜索、RSS 与多媒体。",
13
13
  };
@@ -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;
package/app/globals.css CHANGED
@@ -250,6 +250,11 @@ button {
250
250
  margin-top: 72px;
251
251
  }
252
252
 
253
+ .home-thought-list {
254
+ display: grid;
255
+ gap: 24px;
256
+ }
257
+
253
258
  .section-heading {
254
259
  min-height: 40px;
255
260
  margin-bottom: 21px;
@@ -1443,7 +1448,7 @@ button {
1443
1448
  }
1444
1449
 
1445
1450
  .archive-row.with-cover {
1446
- grid-template-columns: 128px minmax(0, 1fr) 20px;
1451
+ grid-template-columns: 160px minmax(0, 1fr) 20px;
1447
1452
  }
1448
1453
 
1449
1454
  .archive-row:hover {
@@ -1469,10 +1474,9 @@ button {
1469
1474
  }
1470
1475
 
1471
1476
  .archive-cover {
1472
- width: 128px;
1473
- height: 84px;
1477
+ width: 160px;
1478
+ height: auto;
1474
1479
  display: block;
1475
- object-fit: cover;
1476
1480
  border: 1px solid var(--line);
1477
1481
  }
1478
1482
 
@@ -1752,23 +1756,54 @@ button {
1752
1756
  }
1753
1757
 
1754
1758
  .column-feature-index {
1759
+ position: relative;
1760
+ overflow: hidden;
1761
+ background: var(--ink);
1762
+ color: var(--paper);
1763
+ }
1764
+
1765
+ .column-feature-cover {
1766
+ width: 100%;
1767
+ height: 100%;
1768
+ display: block;
1769
+ object-fit: cover;
1770
+ object-position: center;
1771
+ transition: transform 280ms ease;
1772
+ }
1773
+
1774
+ .column-feature:hover .column-feature-cover,
1775
+ .column-feature:focus-visible .column-feature-cover {
1776
+ transform: scale(1.035);
1777
+ }
1778
+
1779
+ .column-feature-index::after {
1780
+ content: "";
1781
+ position: absolute;
1782
+ inset: 0;
1783
+ background: linear-gradient(180deg, rgb(16 18 23 / 20%), rgb(16 18 23 / 8%) 48%, rgb(16 18 23 / 60%));
1784
+ pointer-events: none;
1785
+ }
1786
+
1787
+ .column-feature-index-label {
1788
+ position: absolute;
1789
+ z-index: 1;
1790
+ inset: 0;
1755
1791
  padding: 28px;
1756
1792
  display: flex;
1757
1793
  flex-direction: column;
1758
1794
  justify-content: space-between;
1759
- background: var(--ink);
1760
- color: var(--paper);
1761
1795
  }
1762
1796
 
1763
- .column-feature-index span,
1764
- .column-feature-index small {
1797
+ .column-feature-index-label span,
1798
+ .column-feature-index-label small {
1765
1799
  color: var(--faint);
1766
1800
  font-size: 9px;
1767
1801
  letter-spacing: 0.08em;
1768
1802
  }
1769
1803
 
1770
- .column-feature-index strong {
1804
+ .column-feature-index-label strong {
1771
1805
  align-self: center;
1806
+ color: white;
1772
1807
  font-size: 82px;
1773
1808
  line-height: 1;
1774
1809
  }
@@ -1805,10 +1840,9 @@ button {
1805
1840
 
1806
1841
  .article-hero img {
1807
1842
  width: 100%;
1808
- aspect-ratio: 16 / 8.5;
1843
+ height: auto;
1809
1844
  display: block;
1810
- object-fit: cover;
1811
- border: 1px solid var(--line);
1845
+ border: 0;
1812
1846
  }
1813
1847
 
1814
1848
  .post-reading-page .article-summary-row {
@@ -1854,14 +1888,6 @@ button {
1854
1888
  margin: 28px 0 34px;
1855
1889
  }
1856
1890
 
1857
- .article-cover img {
1858
- width: 100%;
1859
- aspect-ratio: 16 / 8.5;
1860
- display: block;
1861
- object-fit: cover;
1862
- border: 1px solid var(--line);
1863
- }
1864
-
1865
1891
  .article-header h1 {
1866
1892
  margin: 0;
1867
1893
  font-size: clamp(32px, 4.2vw, 48px);
@@ -2107,6 +2133,35 @@ button {
2107
2133
  padding-left: 25px;
2108
2134
  }
2109
2135
 
2136
+ .markdown-body ul {
2137
+ list-style: disc outside;
2138
+ }
2139
+
2140
+ .markdown-body ol {
2141
+ list-style: decimal outside;
2142
+ }
2143
+
2144
+ .markdown-body ul ul {
2145
+ list-style-type: circle;
2146
+ }
2147
+
2148
+ .markdown-body ul ul ul {
2149
+ list-style-type: square;
2150
+ }
2151
+
2152
+ .markdown-body ol ol {
2153
+ list-style-type: lower-alpha;
2154
+ }
2155
+
2156
+ .markdown-body ol ol ol {
2157
+ list-style-type: lower-roman;
2158
+ }
2159
+
2160
+ .markdown-body ul.contains-task-list {
2161
+ padding-left: 0;
2162
+ list-style: none;
2163
+ }
2164
+
2110
2165
  .markdown-body li {
2111
2166
  margin: 7px 0;
2112
2167
  }
@@ -2498,7 +2553,68 @@ button {
2498
2553
  padding-block: 0;
2499
2554
  }
2500
2555
 
2501
- .markdown-body img {
2556
+ .markdown-image-trigger {
2557
+ display: block;
2558
+ max-width: 100%;
2559
+ margin: 34px auto;
2560
+ padding: 0;
2561
+ border: 0;
2562
+ background: transparent;
2563
+ cursor: zoom-in;
2564
+ }
2565
+
2566
+ .markdown-image-trigger img {
2567
+ display: block;
2568
+ max-width: 100%;
2569
+ height: auto;
2570
+ margin: 0;
2571
+ }
2572
+
2573
+ .markdown-image-lightbox {
2574
+ position: fixed;
2575
+ z-index: 100;
2576
+ inset: 0;
2577
+ display: grid;
2578
+ place-items: center;
2579
+ padding: 32px;
2580
+ background: color-mix(in srgb, var(--ink) 82%, transparent);
2581
+ cursor: zoom-out;
2582
+ }
2583
+
2584
+ .markdown-image-lightbox-content {
2585
+ position: relative;
2586
+ max-width: min(1440px, 100%);
2587
+ max-height: 100%;
2588
+ }
2589
+
2590
+ .markdown-image-lightbox-content img {
2591
+ display: block;
2592
+ max-width: 100%;
2593
+ max-height: calc(100vh - 64px);
2594
+ width: auto;
2595
+ height: auto;
2596
+ margin: 0;
2597
+ object-fit: contain;
2598
+ cursor: default;
2599
+ }
2600
+
2601
+ .markdown-image-lightbox-close {
2602
+ position: absolute;
2603
+ top: 12px;
2604
+ right: 12px;
2605
+ padding: 7px 10px;
2606
+ border: 1px solid color-mix(in srgb, white 60%, transparent);
2607
+ background: color-mix(in srgb, var(--ink) 72%, transparent);
2608
+ color: white;
2609
+ font-size: 12px;
2610
+ cursor: pointer;
2611
+ }
2612
+
2613
+ .image-preview-open {
2614
+ overflow: hidden;
2615
+ }
2616
+
2617
+ .markdown-body > img {
2502
2618
  max-width: 100%;
2503
2619
  height: auto;
2504
2620
  margin: 34px auto;
@@ -3975,12 +4091,12 @@ button {
3975
4091
  }
3976
4092
 
3977
4093
  .archive-row.with-cover {
3978
- grid-template-columns: 96px minmax(0, 1fr) 16px;
4094
+ grid-template-columns: 112px minmax(0, 1fr) 16px;
3979
4095
  }
3980
4096
 
3981
4097
  .archive-cover {
3982
- width: 96px;
3983
- height: 68px;
4098
+ width: 112px;
4099
+ height: auto;
3984
4100
  }
3985
4101
 
3986
4102
  .archive-meta {
@@ -4023,15 +4139,15 @@ button {
4023
4139
  }
4024
4140
 
4025
4141
  .column-feature-index {
4026
- min-height: 130px;
4027
- display: grid;
4028
- grid-template-columns: 1fr auto;
4029
- align-items: center;
4142
+ aspect-ratio: 4 / 5;
4143
+ min-height: 0;
4030
4144
  }
4031
4145
 
4032
- .column-feature-index strong {
4033
- grid-column: 2;
4034
- grid-row: 1 / 3;
4146
+ .column-feature-index-label {
4147
+ padding: 20px;
4148
+ }
4149
+
4150
+ .column-feature-index-label strong {
4035
4151
  font-size: 58px;
4036
4152
  }
4037
4153
 
@@ -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>;
package/app/page.tsx CHANGED
@@ -9,10 +9,10 @@ import { columnHref, columns, formatDate, postHref, posts } from "./lib/content"
9
9
  import { ThoughtCard } from "./components/ThoughtCard";
10
10
  import { PageIntro } from "./components/PageIntro";
11
11
  import { getPublishedThoughts } from "./lib/public-thoughts";
12
- import { githubUrl, siteConfig } from "./site.config";
12
+ import { githubUrl, siteConfig, siteDocumentTitle } from "./site.config";
13
13
 
14
14
  export const metadata = {
15
- title: `${siteConfig.name} · ${siteConfig.tagline}`,
15
+ title: siteDocumentTitle,
16
16
  description: siteConfig.description,
17
17
  };
18
18
 
@@ -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>
@@ -33,3 +33,6 @@ export type SiteConfig = {
33
33
  /** 站点身份来自根目录 site.config.yaml,由 garden sync 生成。 */
34
34
  export const siteConfig = raw as SiteConfig;
35
35
  export { githubUrl };
36
+
37
+ /** 列表页浏览器标题用站点名;页面大标题仍用 pages.*.title。 */
38
+ export const siteDocumentTitle = { absolute: siteConfig.name };
@@ -2,11 +2,11 @@ import { getPublishedThoughts } from "../lib/public-thoughts";
2
2
  import { ThoughtCard } from "../components/ThoughtCard";
3
3
  import { ContentEmptyState } from "../components/ContentEmptyState";
4
4
  import { PageIntro } from "../components/PageIntro";
5
- import { siteConfig } from "../site.config";
5
+ import { siteConfig, siteDocumentTitle } from "../site.config";
6
6
 
7
7
  export const metadata = {
8
- title: siteConfig.pages.thoughts.title,
9
- description: siteConfig.pages.thoughts.subtitle,
8
+ title: siteDocumentTitle,
9
+ description: siteConfig.pages.thoughts.subtitle || siteConfig.description,
10
10
  };
11
11
 
12
12
  export default function ThoughtsPage() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.3.2",
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)}`,
@@ -14,6 +14,6 @@
14
14
  "pages:prepare": "garden pages:prepare"
15
15
  },
16
16
  "dependencies": {
17
- "hd-garden": "^0.3.2",
17
+ "hd-garden": "^0.3.3",
18
18
  }
19
19
  }