hd-garden 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/app/components/SiteChrome.tsx +3 -17
  2. package/app/components/SiteHeader.tsx +3 -3
  3. package/app/garden-modules.d.ts +0 -6
  4. package/app/layout.tsx +1 -2
  5. package/app/lib/i18n.ts +2 -12
  6. package/app/sitemap.ts +0 -11
  7. package/app/styles/shell.css +0 -37
  8. package/docs/handbook/config.md +2 -59
  9. package/docs/handbook/en/config.md +2 -59
  10. package/package.json +1 -2
  11. package/scripts/build-content.mjs +1 -31
  12. package/scripts/handbook-facts.mjs +1 -2
  13. package/ui/garden/site-nav.mjs +0 -12
  14. package/vite.config.ts +3 -12
  15. package/app/bookshelf/[bookId]/page.tsx +0 -72
  16. package/app/bookshelf/load.mjs +0 -114
  17. package/app/bookshelf/load.ts +0 -16
  18. package/app/bookshelf/page.tsx +0 -52
  19. package/app/components/bookshelf/BookHub.tsx +0 -142
  20. package/app/components/bookshelf/Bookshelf.tsx +0 -141
  21. package/app/components/bookshelf/bookshelf.css +0 -1352
  22. package/app/components/bookshelf/catalog.mjs +0 -176
  23. package/app/components/bookshelf/catalog.ts +0 -27
  24. package/app/components/bookshelf/demo-data.ts +0 -93
  25. package/app/components/bookshelf/density.ts +0 -32
  26. package/app/components/bookshelf/engine.ts +0 -3057
  27. package/app/components/bookshelf/index.ts +0 -15
  28. package/app/components/bookshelf/model.ts +0 -16
  29. package/app/components/bookshelf/schema.mjs +0 -155
  30. package/app/components/bookshelf/schema.ts +0 -110
  31. package/app/components/bookshelf/types.ts +0 -94
  32. package/public/covers/accelerate.jpg +0 -0
  33. package/public/covers/clean-architecture.jpg +0 -0
  34. package/public/covers/clean-code.jpg +0 -0
  35. package/public/covers/continuous-delivery.jpg +0 -0
  36. package/public/covers/ddd.jpg +0 -0
  37. package/public/covers/ddia.jpg +0 -0
  38. package/public/covers/design-patterns.jpg +0 -0
  39. package/public/covers/eaa.jpg +0 -0
  40. package/public/covers/fundamentals-sa.jpg +0 -0
  41. package/public/covers/implementing-ddd.jpg +0 -0
  42. package/public/covers/lean-startup.jpg +0 -0
  43. package/public/covers/microservices.jpg +0 -0
  44. package/public/covers/mythical-man-month.jpg +0 -0
  45. package/public/covers/phoenix-project.jpg +0 -0
  46. package/public/covers/pragmatic-programmer.jpg +0 -0
  47. package/public/covers/refactoring.jpg +0 -0
  48. package/public/covers/release-it.jpg +0 -0
  49. package/public/covers/sre.jpg +0 -0
  50. package/public/covers/systems-performance.jpg +0 -0
  51. package/public/covers/tdd.jpg +0 -0
  52. package/public/covers/team-topologies.jpg +0 -0
  53. package/scripts/bookshelf-covers.mjs +0 -62
@@ -5,14 +5,7 @@ import { inAppPath, isHandbookPath, routeLang, ui } from "../lib/i18n";
5
5
  import { SiteFooter } from "./SiteFooter";
6
6
  import { SiteHeader } from "./SiteHeader";
7
7
 
8
- export function SiteChrome({
9
- children,
10
- bookshelfSite = false,
11
- }: {
12
- children: React.ReactNode;
13
- /** True when the site has content/bookshelf.yaml. The package demo stays chrome-less. */
14
- bookshelfSite?: boolean;
15
- }) {
8
+ export function SiteChrome({ children }: { children: React.ReactNode }) {
16
9
  const pathname = usePathname();
17
10
  const lang = routeLang(pathname);
18
11
  // The skip link follows the route language too (/garden/en/ is English).
@@ -21,19 +14,12 @@ export function SiteChrome({
21
14
  {ui(lang).skipToContent}
22
15
  </a>
23
16
  );
24
- // The package demo (no content/bookshelf.yaml) is the approved chrome-less viewport.
25
- // A site shelf is a normal page, so it keeps the header and footer.
26
- // Static export serves /bookshelf/ ; an exact "/bookshelf" check would miss it.
27
- const path = inAppPath(pathname);
28
- const shelfDemo = !bookshelfSite && (path === "/bookshelf" || path.startsWith("/bookshelf/"));
29
- if (path.startsWith("/admin") || shelfDemo) {
30
- return <>{skip}{children}</>;
31
- }
17
+ if (inAppPath(pathname).startsWith("/admin")) return <>{skip}{children}</>;
32
18
 
33
19
  return (
34
20
  <>
35
21
  {skip}
36
- <SiteHeader shelf={bookshelfSite} />
22
+ <SiteHeader />
37
23
  <div id="main-content">{children}</div>
38
24
  <SiteFooter docs={isHandbookPath(pathname)} lang={lang} />
39
25
  </>
@@ -16,7 +16,7 @@ import { brand, searchFull, searchIndexUrl } from "@garden/site-config";
16
16
  import { createSearch } from "../../ui/garden/search-match.mjs";
17
17
  import { cropMatchedText, highlightParts } from "../../ui/garden/search-preview.mjs";
18
18
  import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
19
- import { siteNav, isCurrentNav } from "../../ui/garden/site-nav.mjs";
19
+ import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
20
20
  import { isHandbookPath, routeLang, ui } from "../lib/i18n";
21
21
  import { HandbookLangSwitch } from "./HandbookLangSwitch";
22
22
  import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
@@ -89,6 +89,7 @@ function loadSearchEngine() {
89
89
  return engineLoad;
90
90
  }
91
91
 
92
+ const nav = SITE_NAV.map((item) => ({ ...item, key: item.href, href: siteHref(item.href) }));
92
93
 
93
94
  function rangesFor(
94
95
  matches: readonly SearchMatch[] | undefined,
@@ -139,8 +140,7 @@ function searchPreview(
139
140
  return { prefix: "", text: item.description, suffix: "", ranges: [] };
140
141
  }
141
142
 
142
- export function SiteHeader({ shelf = false }: { shelf?: boolean }) {
143
- const nav = siteNav({ shelf }).map((item) => ({ ...item, key: item.href, href: siteHref(item.href) }));
143
+ export function SiteHeader() {
144
144
  const pathname = usePathname() || "/";
145
145
  const t = ui(routeLang(pathname));
146
146
  const [menuOpen, setMenuOpen] = useState(false);
@@ -103,9 +103,3 @@ declare module "@garden/site-config" {
103
103
  };
104
104
  export const githubUrl: string;
105
105
  }
106
-
107
- declare module "@garden/bookshelf" {
108
- import type { BookshelfMount } from "./components/bookshelf/catalog";
109
- /** Site shelf from content/bookshelf.yaml, or null when that file is absent. */
110
- export const bookshelf: BookshelfMount | null;
111
- }
package/app/layout.tsx CHANGED
@@ -4,7 +4,6 @@ import "./globals.css";
4
4
  import "./styles/tokens.css";
5
5
  import "./styles/a2.css";
6
6
  import "./styles/shell.css";
7
- import { bookshelf } from "@garden/bookshelf";
8
7
  import { SiteChrome } from "./components/SiteChrome";
9
8
  import {
10
9
  getSiteUrl,
@@ -95,7 +94,7 @@ export default function RootLayout({
95
94
  )}
96
95
  </head>
97
96
  <body>
98
- <SiteChrome bookshelfSite={bookshelf != null}>{children}</SiteChrome>
97
+ <SiteChrome>{children}</SiteChrome>
99
98
  </body>
100
99
  </html>
101
100
  );
package/app/lib/i18n.ts CHANGED
@@ -131,7 +131,7 @@ const zh = {
131
131
  fuzzy: "支持模糊匹配",
132
132
  escToClose: "关闭",
133
133
  noResults: (q: string) => (q ? `没有找到和「${q}」相关的文章` : "还没有可以搜索的内容"),
134
- navLabels: { "/blog/": "文章", "/thoughts/": "随想", "/columns/": "专栏", "/tags/": "标签", "/bookshelf/": "书架", "/about/": "关于" } as Record<string, string>,
134
+ navLabels: { "/blog/": "文章", "/thoughts/": "随想", "/columns/": "专栏", "/tags/": "标签", "/about/": "关于" } as Record<string, string>,
135
135
  // Home page
136
136
  homeLatestBadge: "最新文章",
137
137
  homeRecentHeading: "最近更新",
@@ -299,11 +299,6 @@ const zh = {
299
299
  adminTitlePreview: "草稿预览",
300
300
  a2IndexTitle: "A2 对照",
301
301
  a2IndexDeck: "对照参考文章的版式。正文没有改字。",
302
- bookHubCurrent: "本书",
303
- bookHubNotes: "我的笔记",
304
- bookHubOverview: "总览",
305
- bookHubExcerpt: "摘抄",
306
- bookHubPdfNote: "(仅分享,侵权删)",
307
302
  };
308
303
 
309
304
  const en: typeof zh = {
@@ -419,7 +414,7 @@ const en: typeof zh = {
419
414
  fuzzy: "Fuzzy matching",
420
415
  escToClose: "to close",
421
416
  noResults: (q: string) => (q ? `No posts match “${q}”` : "Nothing to search yet"),
422
- navLabels: { "/blog/": "Posts", "/thoughts/": "Thoughts", "/columns/": "Columns", "/tags/": "Tags", "/bookshelf/": "Shelf", "/about/": "About" },
417
+ navLabels: { "/blog/": "Posts", "/thoughts/": "Thoughts", "/columns/": "Columns", "/tags/": "Tags", "/about/": "About" },
423
418
  // Home page
424
419
  homeLatestBadge: "Latest post",
425
420
  homeRecentHeading: "Recent updates",
@@ -587,11 +582,6 @@ const en: typeof zh = {
587
582
  adminTitlePreview: "Draft Preview",
588
583
  a2IndexTitle: "A2 Parity",
589
584
  a2IndexDeck: "Layout comparison against reference articles. Body text is unchanged.",
590
- bookHubCurrent: "This book",
591
- bookHubNotes: "My notes",
592
- bookHubOverview: "Overview",
593
- bookHubExcerpt: "Excerpt",
594
- bookHubPdfNote: "(shared only — removed if it infringes)",
595
585
  };
596
586
 
597
587
  export const UI_STRINGS = { zh, en } as const;
package/app/sitemap.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import type { MetadataRoute } from "next";
2
- import { bookshelf } from "@garden/bookshelf";
3
2
  import { columnHref, columns, postHref, posts, thoughtHref } from "./lib/content";
4
3
  import { absoluteUrl, getSiteUrl } from "./lib/site";
5
4
  import { getPublishedThoughts } from "./lib/public-thoughts";
6
5
  import { siteHref } from "./lib/site-href";
7
- import type { BookshelfBook, BookshelfShelf } from "./components/bookshelf/types";
8
6
 
9
7
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
10
8
  const baseUrl = await getSiteUrl();
@@ -16,15 +14,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
16
14
  { path: "/columns", priority: 0.8, changeFrequency: "weekly" as const },
17
15
  { path: "/about", priority: 0.5, changeFrequency: "monthly" as const },
18
16
  { path: "/garden", priority: 0.4, changeFrequency: "monthly" as const },
19
- // Only a real shelf is a page. The package demo stays out of the sitemap.
20
- ...(bookshelf ? [{ path: "/bookshelf", priority: 0.6, changeFrequency: "weekly" as const }] : []),
21
- ...(bookshelf
22
- ? bookshelf.catalog.flatMap((row: BookshelfShelf) =>
23
- row.books
24
- .filter((book: BookshelfBook) => typeof book.hub === "string")
25
- .map((book: BookshelfBook) => ({ path: book.hub ?? "/bookshelf", priority: 0.55, changeFrequency: "weekly" as const })),
26
- )
27
- : []),
28
17
  ].map((route) => ({
29
18
  url: absoluteUrl(siteHref(route.path), baseUrl),
30
19
  changeFrequency: route.changeFrequency,
@@ -434,43 +434,6 @@ html[data-theme="dark"] .sh-repos > li.sk::after{background:linear-gradient(90de
434
434
  .sh-series.no-cover h2,.sh-series.no-cover .count{grid-column:1 / -1;grid-row:auto}
435
435
  }
436
436
 
437
- /* Book hub (本书), locked to tip book-multi-20261029c. Width is fixed; height follows the jacket. */
438
- .book-hub{width:min(100%,740px);padding:40px 0 72px}
439
- .book-hub-crumb{margin:0 0 28px;font-size:12px;color:var(--faint,#a09b91);letter-spacing:.06em}
440
- .book-hub-crumb a{color:inherit;text-decoration:none}
441
- .book-hub-crumb a:hover{color:var(--ink)}
442
- .book-hub-crumb [aria-current="page"]{color:var(--faint,#a09b91)}
443
- .book-hub-card{display:flex;gap:28px;align-items:flex-start;margin:0 0 40px}
444
- .book-hub-cover{width:148px;flex-shrink:0;height:auto;min-height:0;aspect-ratio:auto;border-radius:0;overflow:visible;background:none;box-shadow:none;line-height:0;padding:0}
445
- .book-hub-cover img{display:block;width:148px;max-width:none;height:auto;aspect-ratio:auto;object-fit:unset;background:none;border-radius:4px;box-shadow:0 10px 24px rgba(40,30,20,.10)}
446
- .book-hub-meta{flex:1;min-width:0;padding-top:4px}
447
- .book-hub-meta h1{margin:0 0 10px;font-family:var(--serif);font-size:clamp(26px,4vw,34px);font-weight:600;line-height:1.25;letter-spacing:-.01em;color:var(--ink)}
448
- .book-hub-author{margin:0 0 18px;font-size:14px;color:var(--muted)}
449
- .book-hub-sources{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
450
- .book-hub-source{display:inline-flex;align-items:center;gap:2px}
451
- .book-hub-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:999px;background:#f3efe6;color:var(--muted);font-size:12px;line-height:1.4;text-decoration:none;border:1px solid transparent}
452
- html[data-theme="dark"] .book-hub-chip{background:var(--chip)}
453
- .book-hub-chip:hover{color:var(--ink);border-color:var(--rule)}
454
- .book-hub-chip .ext{font-size:11px;opacity:.7}
455
- .book-hub-source-note{font-size:11px;color:var(--faint,#a09b91);letter-spacing:.02em;line-height:1.4}
456
- .book-hub-notes-head{margin:0 0 14px;font:400 12px/1.4 var(--sans);color:var(--faint,#a09b91);letter-spacing:.12em}
457
- .book-hub-notes{display:flex;flex-direction:column;gap:12px;margin:0;padding:0;list-style:none}
458
- .book-hub-note{display:block;width:100%;text-align:left;padding:18px 20px;background:#fffcf7;border:1px solid var(--rule);border-radius:10px;text-decoration:none;color:inherit}
459
- html[data-theme="dark"] .book-hub-note{background:var(--card)}
460
- @media (hover:hover){
461
- .book-hub-note:hover{border-color:#d5d0c4;box-shadow:0 6px 16px rgba(40,30,20,.05)}
462
- html[data-theme="dark"] .book-hub-note:hover{border-color:var(--rule-strong);box-shadow:none}
463
- }
464
- .book-hub-note-label{margin:0 0 6px;font-size:11px;color:var(--faint,#a09b91);letter-spacing:.08em}
465
- .book-hub-note h3{margin:0 0 8px;font:500 16px/1.45 var(--sans);color:var(--ink)}
466
- .book-hub-note-sum{margin:0 0 12px;font-size:13px;line-height:1.65;color:var(--muted)}
467
- .book-hub-note-meta{margin:0;font-size:12px;color:var(--faint,#a09b91)}
468
- @media (max-width:560px){
469
- .book-hub{padding:28px 0 64px}
470
- .book-hub-card{flex-direction:column;gap:18px}
471
- .book-hub-cover,.book-hub-cover img{width:120px}
472
- }
473
-
474
437
  .sh-row .incol{grid-column:2;justify-self:start;position:relative;z-index:1;margin-top:4px;font-size:var(--fs-meta);line-height:1.6;color:var(--muted);text-decoration:none}
475
438
  @media (hover:hover){.sh-row .incol:hover{color:var(--blue);text-decoration:underline;text-underline-offset:3px}}
476
439
  .is-compact .sh-row .incol{display:none}
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  title: 站点与专栏配置
3
3
  navTitle: 站点与专栏配置
4
- description: 配置 site.config.yaml 中的站点信息、各页标题、自定义品牌图标、content/columns.yaml 专栏与 content/bookshelf.yaml 书架。
4
+ description: 配置 site.config.yaml 中的站点信息、各页标题、自定义品牌图标与 content/columns.yaml 专栏。
5
5
  section: getting-started
6
6
  order: 3
7
7
  ---
8
8
 
9
- 站点的全局文字与外观信息集中在根目录的 `site.config.yaml`,专栏目录集中在 `content/columns.yaml`,读书笔记书架集中在 `content/bookshelf.yaml`。修改后运行 `npx garden sync`(或直接 `make dev`)即可生效。
9
+ 站点的全局文字与外观信息集中在根目录的 `site.config.yaml`,专栏目录集中在 `content/columns.yaml`。修改后运行 `npx garden sync`(或直接 `make dev`)即可生效。
10
10
 
11
11
  ## 1. `site.config.yaml` 完整结构
12
12
 
@@ -125,60 +125,3 @@ columns:
125
125
  | `status` | 否 | 状态标识:`default`(或不写,显示为「持续更新」)、`ongoing`(「连载中」)、`completed`(「已完结」);旧写法 `持续更新`、`连载中`、`已完结` 仍可识别,其他文字按原样显示 |
126
126
  | `cover` | 否 | 专栏封面图路径(相对 `content/covers/`) |
127
127
  | `posts` | 是 | 按阅读顺序排列的文章路径列表(相对于 `content/posts/`,不带 `.md` 后缀) |
128
-
129
- ## 3. 配置读书笔记书架(`content/bookshelf.yaml`)
130
-
131
- `/bookshelf` 在站点写了 `content/bookshelf.yaml` 时渲染这里的书,并带上站点顶栏,和其他页面一样可以被索引。没有这个文件时,`/bookshelf` 只在引擎包内的中文验收里显示内置 demo(无顶栏、不收录);正式站要真书目,就放下这份文件。
132
-
133
- 架上每一条 `books` 是一本书。`post: note/ddia` 是只有一篇笔记时的简写。一本书有多篇笔记时写 `posts`,点书脊播放抽书动画,落到 `/bookshelf/<id>`(本书),不打开第一篇。从本书页回到书架播放合上动画。本书页左边是书衣(宽 148,高度跟图片走),右边是书名、作者,以及豆瓣和 PDF 两枚出处胶囊;PDF 后面有一行「(仅分享,侵权删)」。下面是「我的笔记」,点进去才是文章,这一步没有飞页。单篇笔记仍走原来的抽书和合上。
134
-
135
- `cover` 是书衣,只给书架、抽书飞页和本书页用。文件放在 `content/covers/`,构建时会复制到 `/media/covers/`,即使没有任何博文的 front matter 引用这个文件名。豆瓣封面四周的白边在放进 `content/covers/` 之前裁好,构建不会再改像素。笔记页的横幅仍是各篇 front matter 的 `cover`,不要把那张图填进书衣。yaml 不写 `cover` 时,书衣才沿用第一篇的封面。`color`(`#rgb` 或 `#rrggbb`)画书脊和单篇打开后的纸页。
136
-
137
- ```yaml
138
- title: 读书笔记
139
- description: 读过的书,和留下的笔记。
140
- eyebrow: 花园
141
- shelves:
142
- - id: recent
143
- name: 最近在读
144
- books:
145
- - id: ddia
146
- title: 数据密集型应用系统设计
147
- author: Martin Kleppmann
148
- cover: ddia.jpg
149
- color: "#8b3a2f"
150
- post: note/ddia
151
- - id: unit-testing
152
- title: Unit Testing
153
- cover: unit-testing.jpg
154
- color: "#1a2744"
155
- sources:
156
- - label: 豆瓣
157
- href: https://book.douban.com/subject/00000000/
158
- - label: PDF
159
- href: https://example.com/unit-testing.pdf
160
- posts:
161
- - path: note/note-unit-testing
162
- kind: overview
163
- - path: note/note-unit-testing-excerpt
164
- kind: excerpt
165
- ```
166
-
167
- | 字段 | 必填 | 说明 |
168
- | :--- | :--- | :--- |
169
- | `title` | 是 | 书架页标题 |
170
- | `description` | 否 | 页面简介 |
171
- | `eyebrow` | 否 | 架面眉题。不写时用站点名 |
172
- | `shelves` | 是 | 非空数组。每一层要有 `id`、`name`,以及非空的 `books` |
173
- | `books[].id` | 是 | 整架唯一 |
174
- | `books[].title` | 是 | 书名,显示在左页和笔记页 |
175
- | `books[].author` | 否 | 作者 |
176
- | `books[].spine` | 否 | 书脊文字,默认同 `title` |
177
- | `books[].size` | 否 | `thin`、`s-short`、`s-mid`、`s-tall`。普通书默认 `s-mid`,占位薄册默认 `thin` |
178
- | `books[].cover` | 否 | 书衣。文件名或完整 URL。写了就只用它 |
179
- | `books[].color` | 否 | 书脊和单篇打开后的纸色 |
180
- | `books[].filler` | 否 | `true`、`sand` 或 `cream`,占位薄册,不能打开 |
181
- | `books[].post` | 否 | 单篇笔记的简写 |
182
- | `books[].posts` | 否 | 同一本书下的笔记。`kind` 为 `overview`(总览)或 `excerpt`(摘抄)。两篇及以上打开本书页 |
183
- | `books[].sources` | 否 | 出处。本书页只显示「豆瓣」和「PDF」 |
184
- | `books[].note` | 否 | 没有笔记路径时直接写的 Markdown |
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  title: Site & Column Config
3
3
  navTitle: Site & Column Config
4
- description: Configure site metadata, page headings, and brand icons in site.config.yaml, series in content/columns.yaml, and the bookshelf in content/bookshelf.yaml.
4
+ description: Configure site metadata, page headings, and brand icons in site.config.yaml, and organize series in content/columns.yaml.
5
5
  section: getting-started
6
6
  order: 3
7
7
  ---
8
8
 
9
- Global site copy and appearance settings live in `site.config.yaml` at the repository root, series definitions live in `content/columns.yaml`, and the reading-note shelf lives in `content/bookshelf.yaml`. Run `npx garden sync` (or `make dev`) after editing to apply changes.
9
+ Global site copy and appearance settings live in `site.config.yaml` at the repository root, while series definitions live in `content/columns.yaml`. Run `npx garden sync` (or `make dev`) after editing to apply changes.
10
10
 
11
11
  ## 1. Full `site.config.yaml` Structure
12
12
 
@@ -125,60 +125,3 @@ columns:
125
125
  | `status` | No | Status value: `default` (or omitted, shown as "Active"), `ongoing` ("Ongoing"), or `completed` ("Completed"). Legacy Chinese values are still accepted, and any other text is shown verbatim |
126
126
  | `cover` | No | Cover image path relative to `content/covers/` |
127
127
  | `posts` | Yes | Ordered array of post paths relative to `content/posts/`, without the `.md` extension |
128
-
129
- ## 3. Configuring the bookshelf (`content/bookshelf.yaml`)
130
-
131
- When a site has `content/bookshelf.yaml`, `/bookshelf` renders those books inside the site header and is indexed with the other pages. Without that file, `/bookshelf` is only the package's built-in demo (Chinese click-test, no header, not indexed). A production site supplies the file.
132
-
133
- Each `books` entry is one volume. `post: note/ddia` is the one-note shorthand. Several notes use `posts`. Clicking that spine plays the extract animation and lands on `/bookshelf/<id>`. It does not open the first note. Returning from that page to the shelf plays the close animation. The book page shows a 148px-wide jacket (height follows the image), the title, the author, and only the Douban and PDF chips. The PDF chip is followed by a short sharing note. My notes lists the notes; each card is a normal link to the article and does not play the flyer. A single note still uses the extract and return animation.
134
-
135
- `cover` is the jacket for the shelf, the flyer, and the book page. The file lives in `content/covers/` and the build copies it to `/media/covers/`, even when no post front matter names that file. Crop a Douban scan's white margin before committing the file; the build does not change pixels. A note's hero stays that post's front matter `cover`. Omit the jacket and it falls back to the first note's cover. `color` (`#rgb` or `#rrggbb`) is the spine and the single-note open page.
136
-
137
- ```yaml
138
- title: Reading notes
139
- description: Books I have read, and the notes I kept.
140
- eyebrow: Library
141
- shelves:
142
- - id: recent
143
- name: Recent
144
- books:
145
- - id: ddia
146
- title: Designing Data-Intensive Applications
147
- author: Martin Kleppmann
148
- cover: ddia.jpg
149
- color: "#8b3a2f"
150
- post: note/ddia
151
- - id: unit-testing
152
- title: Unit Testing
153
- cover: unit-testing.jpg
154
- color: "#1a2744"
155
- sources:
156
- - label: Douban
157
- href: https://book.douban.com/subject/00000000/
158
- - label: PDF
159
- href: https://example.com/unit-testing.pdf
160
- posts:
161
- - path: note/note-unit-testing
162
- kind: overview
163
- - path: note/note-unit-testing-excerpt
164
- kind: excerpt
165
- ```
166
-
167
- | Field | Required | Description |
168
- | :--- | :--- | :--- |
169
- | `title` | Yes | Page title |
170
- | `description` | No | Page summary |
171
- | `eyebrow` | No | Small line above the title. Falls back to the site name |
172
- | `shelves` | Yes | Non-empty list. Each shelf needs `id`, `name`, and a non-empty `books` list |
173
- | `books[].id` | Yes | Unique across the shelf |
174
- | `books[].title` | Yes | Title on the left page and the note view |
175
- | `books[].author` | No | Author |
176
- | `books[].spine` | No | Spine label. Defaults to `title` |
177
- | `books[].size` | No | `thin`, `s-short`, `s-mid`, or `s-tall`. Real books default to `s-mid`; filler spines default to `thin` |
178
- | `books[].cover` | No | Jacket. File name or full URL. When set, it is the only jacket |
179
- | `books[].color` | No | Spine and single-note open-page color |
180
- | `books[].filler` | No | `true`, `sand`, or `cream`. A thin spacer that does not open |
181
- | `books[].post` | No | Shorthand for one note |
182
- | `books[].posts` | No | Notes on this volume. `kind` is `overview` or `excerpt`. Two or more open the book page |
183
- | `books[].sources` | No | Sources. The book page shows Douban and PDF only |
184
- | `books[].note` | No | Markdown used only when the book names no posts |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -31,7 +31,6 @@
31
31
  "public/apple-touch-icon.png",
32
32
  "public/og.png",
33
33
  "public/images",
34
- "public/covers",
35
34
  "scripts",
36
35
  "templates",
37
36
  "ui",
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { fileURLToPath } from "node:url";
5
5
  import matter from "gray-matter";
6
6
  import { marked } from "marked";
7
7
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
@@ -994,36 +994,6 @@ const sitemapEntries = [
994
994
  ...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
995
995
  ].map(pageAddress)),
996
996
  ];
997
- // A site shelf is a public page. The package demo (no content/bookshelf.yaml) is not.
998
- // The app imports this module instead of reading the yaml at request time, so the
999
- // worker preview and the static export both see the same shelf.
1000
- // Forked script copies (pipeline tests) do not ship app/bookshelf; a missing loader
1001
- // means there is no site shelf, same as a package without the file.
1002
- const bookshelfLoaderPath = path.join(gardenPackageRoot, "app", "bookshelf", "load.mjs");
1003
- const loadBookshelf = fs.existsSync(bookshelfLoaderPath)
1004
- ? (await import(pathToFileURL(bookshelfLoaderPath).href)).loadBookshelf
1005
- : () => null;
1006
- const bookshelf = loadBookshelf(root, posts);
1007
- fs.mkdirSync(gardenDir, { recursive: true });
1008
- fs.writeFileSync(
1009
- path.join(gardenDir, "bookshelf.ts"),
1010
- `// Generated by garden sync. Edit content/bookshelf.yaml instead.\nexport const bookshelf = ${JSON.stringify(bookshelf)} as const;\n`,
1011
- );
1012
- if (bookshelf) {
1013
- sitemapEntries.push(pageAddress("/bookshelf/"));
1014
- for (const row of bookshelf.catalog ?? []) {
1015
- for (const book of row.books ?? []) {
1016
- if (typeof book.hub === "string" && book.hub.startsWith("/")) sitemapEntries.push(pageAddress(book.hub));
1017
- }
1018
- }
1019
- const { publishBookshelfJackets } = await import(pathToFileURL(path.join(gardenPackageRoot, "scripts", "bookshelf-covers.mjs")).href);
1020
- const { parseBookshelfConfig } = await import(pathToFileURL(path.join(gardenPackageRoot, "app", "components", "bookshelf", "schema.mjs")).href);
1021
- const coverConfig = parseBookshelfConfig(parseYaml(fs.readFileSync(path.join(contentRoot, "bookshelf.yaml"), "utf8")));
1022
- publishBookshelfJackets(coverConfig, {
1023
- coversDir: path.join(contentRoot, "covers"),
1024
- mediaDir: path.join(generatedMediaRoot, "covers"),
1025
- });
1026
- }
1027
997
  // Card URLs in published entries: the only pages the link-card Worker may read.
1028
998
  fs.writeFileSync(
1029
999
  path.join(publicRoot, "link-allowlist.json"),
@@ -743,14 +743,13 @@ export function checkFileFacts(filePath, content, facts, root = ROOT) {
743
743
  const isWorkflow =
744
744
  /\b(?:runs-on|steps|jobs|workflow_dispatch|actions\/checkout)\b/u.test(bodyText);
745
745
  const isColumnsYaml = /^columns:\s*$/mu.test(bodyText) && /^\s*-\s+slug:/mu.test(bodyText);
746
- const isBookshelfYaml = /^shelves:\s*$/mu.test(bodyText) && /^\s*-\s+id:/mu.test(bodyText) && /\bbooks:/u.test(bodyText);
747
746
  const isFrontmatter = /^\s*---\s*$/mu.test(bodyText) || (/\bdraft:/u.test(bodyText) && /\btags:/u.test(bodyText));
748
747
 
749
748
  if (isWorkflow) {
750
749
  checkWorkflowRunLines(fence, relFile, facts, issues);
751
750
  }
752
751
 
753
- if (!isWorkflow && !isColumnsYaml && !isBookshelfYaml && !isFrontmatter) {
752
+ if (!isWorkflow && !isColumnsYaml && !isFrontmatter) {
754
753
  try {
755
754
  const parsed = parseYaml(bodyText);
756
755
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
@@ -11,18 +11,6 @@ export const SITE_NAV = [
11
11
  { href: "/about/", label: "关于" },
12
12
  ];
13
13
 
14
- /**
15
- * Header links. A site with `content/bookshelf.yaml` gains 书架; the package
16
- * demo (no file) does not advertise a page it only click-tests.
17
- */
18
- export function siteNav({ shelf = false } = {}) {
19
- if (!shelf) return SITE_NAV;
20
- const entry = { href: "/bookshelf/", label: "书架" };
21
- const about = SITE_NAV.findIndex((item) => item.href === "/about/");
22
- if (about < 0) return [...SITE_NAV, entry];
23
- return [...SITE_NAV.slice(0, about), entry, ...SITE_NAV.slice(about)];
24
- }
25
-
26
14
  const trim = (value) => (value.length > 1 ? value.replace(/\/+$/u, "") : value);
27
15
 
28
16
  /**
package/vite.config.ts CHANGED
@@ -14,11 +14,9 @@ const { d1, r2 } = hostingConfig;
14
14
  const siteRoot = gardenSiteRoot();
15
15
  const generatedContent = path.join(siteRoot, ".garden", "generated-content.ts");
16
16
  const generatedSiteConfig = path.join(siteRoot, ".garden", "site-config.ts");
17
- const generatedBookshelf = path.join(siteRoot, ".garden", "bookshelf.ts");
18
17
  const generatedAliases = {
19
18
  "@garden/generated-content": generatedContent,
20
19
  "@garden/site-config": generatedSiteConfig,
21
- "@garden/bookshelf": generatedBookshelf,
22
20
  };
23
21
 
24
22
  // macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
@@ -69,11 +67,10 @@ function gardenContent() {
69
67
  const normalized = id.split("?")[0];
70
68
  if (normalized === "@garden/generated-content") return generatedContent;
71
69
  if (normalized === "@garden/site-config") return generatedSiteConfig;
72
- if (normalized === "@garden/bookshelf") return generatedBookshelf;
73
70
  return undefined;
74
71
  },
75
72
  buildStart() {
76
- if (!fs.existsSync(generatedContent) || !fs.existsSync(generatedSiteConfig) || !fs.existsSync(generatedBookshelf)) {
73
+ if (!fs.existsSync(generatedContent) || !fs.existsSync(generatedSiteConfig)) {
77
74
  sync();
78
75
  }
79
76
  },
@@ -81,19 +78,13 @@ function gardenContent() {
81
78
  const contentDir = path.join(siteRoot, "content");
82
79
  const configFile = path.join(siteRoot, "site.config.yaml");
83
80
  server.watcher.add([contentDir, configFile]);
84
- const resyncContent = (file: string) => {
81
+ server.watcher.on("change", (file) => {
85
82
  if (file.startsWith(contentDir) || file === configFile) {
86
83
  // While editing, a broken ui block shows as an error card instead of stopping the server.
87
84
  sync("preview");
88
85
  server.ws.send({ type: "full-reload" });
89
86
  }
90
- };
91
- server.watcher.on("change", resyncContent);
92
- // A new or removed bookshelf.yaml is not a "change". Sync so /bookshelf
93
- // switches between the site shelf and the package demo without a restart.
94
- const isBookshelf = (file: string) => file.endsWith(`${path.sep}bookshelf.yaml`) || file.endsWith("/bookshelf.yaml");
95
- server.watcher.on("add", (file) => { if (isBookshelf(file)) resyncContent(file); });
96
- server.watcher.on("unlink", (file) => { if (isBookshelf(file)) resyncContent(file); });
87
+ });
97
88
  },
98
89
  };
99
90
  }
@@ -1,72 +0,0 @@
1
- import type { Metadata } from "next";
2
- import { notFound } from "next/navigation";
3
- import { bookshelf } from "@garden/bookshelf";
4
- import { Bookshelf } from "../../components/bookshelf";
5
- import { pageAlternates } from "../../lib/page-alternates";
6
- import { siteConfig } from "../../site.config";
7
- import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
8
- import type { BookshelfBook } from "../../components/bookshelf/types";
9
-
10
- function hubBooks(): { shelfTitle: string; book: BookshelfBook }[] {
11
- if (!bookshelf) return [];
12
- const found: { shelfTitle: string; book: BookshelfBook }[] = [];
13
- for (const row of bookshelf.catalog) {
14
- for (const book of row.books) {
15
- if ((book.posts?.length ?? 0) > 1) found.push({ shelfTitle: bookshelf.title, book });
16
- }
17
- }
18
- return found;
19
- }
20
-
21
- export function generateStaticParams() {
22
- return staticParams(
23
- hubBooks().map(({ book }) => ({ bookId: book.id })),
24
- { bookId: STATIC_PLACEHOLDER },
25
- );
26
- }
27
-
28
- export async function generateMetadata({
29
- params,
30
- }: {
31
- params: Promise<{ bookId: string }>;
32
- }): Promise<Metadata> {
33
- const { bookId } = await params;
34
- const found = hubBooks().find((item) => item.book.id === bookId);
35
- if (!found) return {};
36
- return {
37
- title: found.book.title,
38
- description: found.book.posts?.find((post) => post.summary)?.summary,
39
- alternates: pageAlternates(`/bookshelf/${found.book.id}`),
40
- };
41
- }
42
-
43
- /** `/bookshelf/<id>` — one multi-note volume. Single-note books stay on the shelf. */
44
- export default async function BookHubPage({
45
- params,
46
- }: {
47
- params: Promise<{ bookId: string }>;
48
- }) {
49
- const { bookId } = await params;
50
- const found = hubBooks().find((item) => item.book.id === bookId);
51
- if (!found || !bookshelf) {
52
- if (isStaticPlaceholder(bookId)) return null;
53
- notFound();
54
- }
55
- return (
56
- <main className="sh-main bookshelf-page" id="content">
57
- <header className="sh-head">
58
- {bookshelf.eyebrow ? <p className="sh-kicker">{bookshelf.eyebrow}</p> : null}
59
- <h1 id="sh-title">{bookshelf.title}</h1>
60
- {bookshelf.description ? <p className="sh-deck">{bookshelf.description}</p> : null}
61
- </header>
62
- <Bookshelf
63
- embedded
64
- catalog={bookshelf.catalog}
65
- notes={bookshelf.notes}
66
- title={bookshelf.title}
67
- eyebrow={bookshelf.eyebrow ?? siteConfig.name}
68
- initialHub={found.book.id}
69
- />
70
- </main>
71
- );
72
- }