hd-garden 0.9.0 → 0.9.2

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.
@@ -0,0 +1,86 @@
1
+ export class BookshelfConfigError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.name = "BookshelfConfigError";
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ const SIZES = new Set(["thin", "s-short", "s-mid", "s-tall"]);
10
+
11
+ function record(value, code, message) {
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new BookshelfConfigError(code, message);
13
+ return value;
14
+ }
15
+
16
+ function optionalString(value) {
17
+ if (typeof value !== "string") return undefined;
18
+ const trimmed = value.trim();
19
+ return trimmed ? trimmed : undefined;
20
+ }
21
+
22
+ function parseFiller(value, where) {
23
+ if (value == null || value === false) return undefined;
24
+ if (value === true || value === "sand" || value === "cream") return value;
25
+ throw new BookshelfConfigError("filler", `${where} 的 filler 只能是 true、sand 或 cream。`);
26
+ }
27
+
28
+ function parseSize(value, where) {
29
+ if (value == null || value === "") return undefined;
30
+ if (typeof value === "string" && SIZES.has(value)) return value;
31
+ throw new BookshelfConfigError("size", `${where} 的 size 只能是 thin、s-short、s-mid 或 s-tall。`);
32
+ }
33
+
34
+ function parseBook(value, where, seen) {
35
+ const book = record(value, "book", `${where} 必须是映射。`);
36
+ const id = optionalString(book.id);
37
+ const title = optionalString(book.title);
38
+ if (!id || !title) throw new BookshelfConfigError("book", `${where} 需要 id 和 title。`);
39
+ if (seen.has(id)) throw new BookshelfConfigError("duplicate-id", `${where} 的 id「${id}」重复。`);
40
+ seen.add(id);
41
+ return {
42
+ id,
43
+ title,
44
+ author: optionalString(book.author),
45
+ spine: optionalString(book.spine),
46
+ size: parseSize(book.size, where),
47
+ cover: optionalString(book.cover),
48
+ color: optionalString(book.color),
49
+ filler: parseFiller(book.filler, where),
50
+ post: optionalString(book.post),
51
+ note: typeof book.note === "string" && book.note.trim() ? book.note.trim() : undefined,
52
+ };
53
+ }
54
+
55
+ function parseShelf(value, index, seen) {
56
+ const where = `shelves[${index}]`;
57
+ const shelf = record(value, "shelf", `${where} 必须是映射。`);
58
+ const id = optionalString(shelf.id);
59
+ const name = optionalString(shelf.name);
60
+ if (!id || !name) throw new BookshelfConfigError("shelf", `${where} 需要 id 和 name。`);
61
+ if (!Array.isArray(shelf.books) || shelf.books.length === 0) {
62
+ throw new BookshelfConfigError("shelf", `${where} 的 books 必须是非空数组。`);
63
+ }
64
+ return {
65
+ id,
66
+ name,
67
+ books: shelf.books.map((book, bookIndex) => parseBook(book, `${where}.books[${bookIndex}]`, seen)),
68
+ };
69
+ }
70
+
71
+ /** Validate `content/bookshelf.yaml`. Throws `BookshelfConfigError` with a stable `code`. */
72
+ export function parseBookshelfConfig(input) {
73
+ const data = record(input, "invalid-root", "content/bookshelf.yaml 必须是映射。");
74
+ const title = optionalString(data.title);
75
+ if (!title) throw new BookshelfConfigError("missing-title", "content/bookshelf.yaml 缺少 title。");
76
+ if (!Array.isArray(data.shelves) || data.shelves.length === 0) {
77
+ throw new BookshelfConfigError("shelves", "content/bookshelf.yaml 的 shelves 必须是非空数组。");
78
+ }
79
+ const seen = new Set();
80
+ return {
81
+ title,
82
+ description: optionalString(data.description),
83
+ eyebrow: optionalString(data.eyebrow),
84
+ shelves: data.shelves.map((shelf, index) => parseShelf(shelf, index, seen)),
85
+ };
86
+ }
@@ -1,4 +1,5 @@
1
1
  import type { BookshelfFiller, BookshelfSize } from "./model";
2
+ import { BookshelfConfigError as ConfigError, parseBookshelfConfig as parseConfig } from "./schema.mjs";
2
3
 
3
4
  export type BookshelfConfigCode =
4
5
  | "invalid-root"
@@ -8,32 +9,43 @@ export type BookshelfConfigCode =
8
9
  | "book"
9
10
  | "duplicate-id"
10
11
  | "size"
11
- | "filler";
12
+ | "filler"
13
+ | "cover"
14
+ | "post";
12
15
 
13
- export class BookshelfConfigError extends Error {
16
+ export interface BookshelfConfigError extends Error {
14
17
  readonly code: BookshelfConfigCode;
15
-
16
- constructor(code: BookshelfConfigCode, message: string) {
17
- super(message);
18
- this.name = "BookshelfConfigError";
19
- this.code = code;
20
- }
21
18
  }
22
19
 
20
+ /** Runtime class lives in `schema.mjs` so node tests and the site build share one instance. */
21
+ export const BookshelfConfigError = ConfigError as unknown as new (
22
+ code: BookshelfConfigCode,
23
+ message: string,
24
+ ) => BookshelfConfigError;
25
+
26
+ /**
27
+ * One volume in `content/bookshelf.yaml`.
28
+ *
29
+ * Site contract (what a blog drops in to replace the package demo):
30
+ * - `post` is a path under `content/posts/`, without `.md`. The shelf renders that
31
+ * post as the open-book note (front matter `description` is the short summary).
32
+ * - `cover` is either a full URL (`https://…`, `//…`, or a site path starting
33
+ * with `/`) or a file name. A file name is published at `/media/covers/<file>`,
34
+ * the same URL `content/covers/` gets when a post's `cover` names that file.
35
+ * Omit `cover` to reuse the post's own cover.
36
+ * - `color` (`#rgb` or `#rrggbb`) is the spine cloth and the open-page palette.
37
+ * - `note` is Markdown used only when `post` is absent.
38
+ */
23
39
  export type BookshelfFileBook = {
24
40
  id: string;
25
41
  title: string;
26
42
  author?: string;
27
43
  spine?: string;
28
44
  size?: BookshelfSize;
29
- /** Douban or same-site cover URL. Absent → text placeholder. */
30
45
  cover?: string;
31
- /** Spine and cloth color. Sample this from the cover when you have one. */
32
46
  color?: string;
33
47
  filler?: BookshelfFiller;
34
- /** Path under content/posts, without `.md`. */
35
48
  post?: string;
36
- /** Markdown note used when `post` is absent. */
37
49
  note?: string;
38
50
  };
39
51
 
@@ -50,81 +62,7 @@ export type BookshelfFile = {
50
62
  shelves: BookshelfFileShelf[];
51
63
  };
52
64
 
53
- const SIZES = new Set<BookshelfSize>(["thin", "s-short", "s-mid", "s-tall"]);
54
-
55
- function record(value: unknown, code: BookshelfConfigCode, message: string): Record<string, unknown> {
56
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new BookshelfConfigError(code, message);
57
- return value as Record<string, unknown>;
58
- }
59
-
60
- function optionalString(value: unknown): string | undefined {
61
- if (typeof value !== "string") return undefined;
62
- const trimmed = value.trim();
63
- return trimmed ? trimmed : undefined;
64
- }
65
-
66
- function parseFiller(value: unknown, where: string): BookshelfFiller | undefined {
67
- if (value == null || value === false) return undefined;
68
- if (value === true || value === "sand" || value === "cream") return value;
69
- throw new BookshelfConfigError("filler", `${where} 的 filler 只能是 true、sand 或 cream。`);
70
- }
71
-
72
- function parseSize(value: unknown, where: string): BookshelfSize | undefined {
73
- if (value == null || value === "") return undefined;
74
- if (typeof value === "string" && SIZES.has(value as BookshelfSize)) return value as BookshelfSize;
75
- throw new BookshelfConfigError("size", `${where} 的 size 只能是 thin、s-short、s-mid 或 s-tall。`);
76
- }
77
-
78
- function parseBook(value: unknown, where: string, seen: Set<string>): BookshelfFileBook {
79
- const book = record(value, "book", `${where} 必须是映射。`);
80
- const id = optionalString(book.id);
81
- const title = optionalString(book.title);
82
- if (!id || !title) throw new BookshelfConfigError("book", `${where} 需要 id 和 title。`);
83
- if (seen.has(id)) throw new BookshelfConfigError("duplicate-id", `${where} 的 id「${id}」重复。`);
84
- seen.add(id);
85
- return {
86
- id,
87
- title,
88
- author: optionalString(book.author),
89
- spine: optionalString(book.spine),
90
- size: parseSize(book.size, where),
91
- cover: optionalString(book.cover),
92
- color: optionalString(book.color),
93
- filler: parseFiller(book.filler, where),
94
- post: optionalString(book.post),
95
- note: typeof book.note === "string" && book.note.trim() ? book.note.trim() : undefined,
96
- };
97
- }
98
-
99
- function parseShelf(value: unknown, index: number, seen: Set<string>): BookshelfFileShelf {
100
- const where = `shelves[${index}]`;
101
- const shelf = record(value, "shelf", `${where} 必须是映射。`);
102
- const id = optionalString(shelf.id);
103
- const name = optionalString(shelf.name);
104
- if (!id || !name) throw new BookshelfConfigError("shelf", `${where} 需要 id 和 name。`);
105
- if (!Array.isArray(shelf.books) || shelf.books.length === 0) {
106
- throw new BookshelfConfigError("shelf", `${where} 的 books 必须是非空数组。`);
107
- }
108
- return {
109
- id,
110
- name,
111
- books: shelf.books.map((book, bookIndex) => parseBook(book, `${where}.books[${bookIndex}]`, seen)),
112
- };
113
- }
114
-
115
65
  /** Validate `content/bookshelf.yaml`. Throws `BookshelfConfigError` with a stable `code`. */
116
66
  export function parseBookshelfConfig(input: unknown): BookshelfFile {
117
- const data = record(input, "invalid-root", "content/bookshelf.yaml 必须是映射。");
118
- const title = optionalString(data.title);
119
- if (!title) throw new BookshelfConfigError("missing-title", "content/bookshelf.yaml 缺少 title。");
120
- if (!Array.isArray(data.shelves) || data.shelves.length === 0) {
121
- throw new BookshelfConfigError("shelves", "content/bookshelf.yaml 的 shelves 必须是非空数组。");
122
- }
123
- const seen = new Set<string>();
124
- return {
125
- title,
126
- description: optionalString(data.description),
127
- eyebrow: optionalString(data.eyebrow),
128
- shelves: data.shelves.map((shelf, index) => parseShelf(shelf, index, seen)),
129
- };
67
+ return parseConfig(input) as BookshelfFile;
130
68
  }
@@ -4,9 +4,10 @@ export type { BookshelfFiller, BookshelfSize };
4
4
 
5
5
  /**
6
6
  * One volume on the shelf.
7
- * `cover: true` uses the approved jacket at `/covers/<id>.jpg`.
8
- * A string is a cover URL (Douban). Falsy covers are text spines and are omitted in state A.
9
- * `color` is the spine cloth. It should be sampled from the cover.
7
+ * `cover: true` uses the package-demo jacket at `/covers/<id>.jpg`.
8
+ * A string is a cover URL (`/media/covers/…` for a site shelf, or any absolute URL).
9
+ * Falsy covers are text spines and are omitted in state A.
10
+ * `color` is the spine cloth and the open-page palette. Sample it from the cover.
10
11
  */
11
12
  export type BookshelfBook = {
12
13
  id: string;
@@ -19,6 +20,10 @@ export type BookshelfBook = {
19
20
  filler?: BookshelfFiller;
20
21
  /** Key into the notes map. Null means the demo placeholder paragraph. */
21
22
  note?: string | null;
23
+ /** `content/posts` path without `.md`, when the note is a post. */
24
+ post?: string;
25
+ /** Full-note address, shaped for this build. Set when `post` is set. */
26
+ href?: string;
22
27
  };
23
28
 
24
29
  export type BookshelfShelf = {
@@ -30,9 +35,21 @@ export type BookshelfShelf = {
30
35
  export type BookshelfNote = {
31
36
  title: string;
32
37
  body: string;
38
+ /** The post's front matter description. The open book's right page. */
39
+ summary?: string;
33
40
  };
34
41
 
35
42
  export type BookshelfProps = {
43
+ /** Site shelf. Omit both `catalog` and `notes` for the package demo. */
36
44
  catalog?: BookshelfShelf[];
37
45
  notes?: Record<string, BookshelfNote>;
46
+ /** Heading on the shelf. Omit for the package-demo title. */
47
+ title?: string;
48
+ /** Small line above the heading. Omit for the package-demo brand. */
49
+ eyebrow?: string;
50
+ /**
51
+ * Site page: the shelf sits in the blog column. The package demo leaves this
52
+ * unset and keeps its own full-viewport frame.
53
+ */
54
+ embedded?: boolean;
38
55
  };
@@ -103,3 +103,9 @@ 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,6 +4,7 @@ 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";
7
8
  import { SiteChrome } from "./components/SiteChrome";
8
9
  import {
9
10
  getSiteUrl,
@@ -94,7 +95,7 @@ export default function RootLayout({
94
95
  )}
95
96
  </head>
96
97
  <body>
97
- <SiteChrome>{children}</SiteChrome>
98
+ <SiteChrome bookshelfSite={bookshelf != null}>{children}</SiteChrome>
98
99
  </body>
99
100
  </html>
100
101
  );
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/": "标签", "/about/": "关于" } as Record<string, string>,
134
+ navLabels: { "/blog/": "文章", "/thoughts/": "随想", "/columns/": "专栏", "/tags/": "标签", "/bookshelf/": "书架", "/about/": "关于" } as Record<string, string>,
135
135
  // Home page
136
136
  homeLatestBadge: "最新文章",
137
137
  homeRecentHeading: "最近更新",
@@ -414,7 +414,7 @@ const en: typeof zh = {
414
414
  fuzzy: "Fuzzy matching",
415
415
  escToClose: "to close",
416
416
  noResults: (q: string) => (q ? `No posts match “${q}”` : "Nothing to search yet"),
417
- navLabels: { "/blog/": "Posts", "/thoughts/": "Thoughts", "/columns/": "Columns", "/tags/": "Tags", "/about/": "About" },
417
+ navLabels: { "/blog/": "Posts", "/thoughts/": "Thoughts", "/columns/": "Columns", "/tags/": "Tags", "/bookshelf/": "Shelf", "/about/": "About" },
418
418
  // Home page
419
419
  homeLatestBadge: "Latest post",
420
420
  homeRecentHeading: "Recent updates",
package/app/sitemap.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { MetadataRoute } from "next";
2
+ import { bookshelf } from "@garden/bookshelf";
2
3
  import { columnHref, columns, postHref, posts, thoughtHref } from "./lib/content";
3
4
  import { absoluteUrl, getSiteUrl } from "./lib/site";
4
5
  import { getPublishedThoughts } from "./lib/public-thoughts";
@@ -14,6 +15,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
14
15
  { path: "/columns", priority: 0.8, changeFrequency: "weekly" as const },
15
16
  { path: "/about", priority: 0.5, changeFrequency: "monthly" as const },
16
17
  { path: "/garden", priority: 0.4, changeFrequency: "monthly" as const },
18
+ // Only a real shelf is a page. The package demo stays out of the sitemap.
19
+ ...(bookshelf ? [{ path: "/bookshelf", priority: 0.6, changeFrequency: "weekly" as const }] : []),
17
20
  ].map((route) => ({
18
21
  url: absoluteUrl(siteHref(route.path), baseUrl),
19
22
  changeFrequency: route.changeFrequency,
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  title: 站点与专栏配置
3
3
  navTitle: 站点与专栏配置
4
- description: 配置 site.config.yaml 中的站点信息、各页标题、自定义品牌图标与 content/columns.yaml 专栏。
4
+ description: 配置 site.config.yaml 中的站点信息、各页标题、自定义品牌图标、content/columns.yaml 专栏与 content/bookshelf.yaml 书架。
5
5
  section: getting-started
6
6
  order: 3
7
7
  ---
8
8
 
9
- 站点的全局文字与外观信息集中在根目录的 `site.config.yaml`,专栏目录集中在 `content/columns.yaml`。修改后运行 `npx garden sync`(或直接 `make dev`)即可生效。
9
+ 站点的全局文字与外观信息集中在根目录的 `site.config.yaml`,专栏目录集中在 `content/columns.yaml`,读书笔记书架集中在 `content/bookshelf.yaml`。修改后运行 `npx garden sync`(或直接 `make dev`)即可生效。
10
10
 
11
11
  ## 1. `site.config.yaml` 完整结构
12
12
 
@@ -125,3 +125,42 @@ 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
+ 每本书的 `post` 是 `content/posts/` 下的路径,不带 `.md`。打开后的左页是书名,右页是这篇的摘要(front matter 的 `description`),笔记页是正文。封面写文件名时,地址是 `/media/covers/<文件名>`,和博文 front matter 的 `cover` 一致,文件放在 `content/covers/`。已经写了 `https://…`,或以 `/` 开头的地址,则原样使用。不写 `cover` 时,沿用这篇博文自己的封面。`color` 取封面的主色(`#rgb` 或 `#rrggbb`),用来画书脊和打开后的纸页。
134
+
135
+ ```yaml
136
+ title: 读书笔记
137
+ description: 读过的书,和留下的笔记。
138
+ eyebrow: 花园
139
+ shelves:
140
+ - id: recent
141
+ name: 最近在读
142
+ books:
143
+ - id: ddia
144
+ title: 数据密集型应用系统设计
145
+ author: Martin Kleppmann
146
+ cover: ddia.jpg
147
+ color: "#8b3a2f"
148
+ post: note/ddia
149
+ ```
150
+
151
+ | 字段 | 必填 | 说明 |
152
+ | :--- | :--- | :--- |
153
+ | `title` | 是 | 书架页标题 |
154
+ | `description` | 否 | 页面简介 |
155
+ | `eyebrow` | 否 | 架面眉题。不写时用站点名 |
156
+ | `shelves` | 是 | 非空数组。每一层要有 `id`、`name`,以及非空的 `books` |
157
+ | `books[].id` | 是 | 整架唯一 |
158
+ | `books[].title` | 是 | 书名,显示在左页和笔记页 |
159
+ | `books[].author` | 否 | 作者 |
160
+ | `books[].spine` | 否 | 书脊文字,默认同 `title` |
161
+ | `books[].size` | 否 | `thin`、`s-short`、`s-mid`、`s-tall`。普通书默认 `s-mid`,占位薄册默认 `thin` |
162
+ | `books[].cover` | 否 | 文件名,或完整 URL |
163
+ | `books[].color` | 否 | 书脊和打开后的纸色 |
164
+ | `books[].filler` | 否 | `true`、`sand` 或 `cream`,占位薄册,不能打开 |
165
+ | `books[].post` | 否 | 笔记正文所在的博文 |
166
+ | `books[].note` | 否 | 没有 `post` 时直接写的 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, and organize series in content/columns.yaml.
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.
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, while series definitions live in `content/columns.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, 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.
10
10
 
11
11
  ## 1. Full `site.config.yaml` Structure
12
12
 
@@ -125,3 +125,42 @@ 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
+ `post` is a path under `content/posts/`, without `.md`. The open book shows the title on the left page and that post's front matter `description` on the right; the note view shows the body. A cover file name is published at `/media/covers/<file>`, the same URL a post's `cover` uses for a file in `content/covers/`. A value that is already `https://…` or starts with `/` is used as written. Omit `cover` to reuse the post's own cover. `color` (`#rgb` or `#rrggbb`) is the spine cloth and the open-page palette, sampled from the cover.
134
+
135
+ ```yaml
136
+ title: Reading notes
137
+ description: Books I have read, and the notes I kept.
138
+ eyebrow: Library
139
+ shelves:
140
+ - id: recent
141
+ name: Recent
142
+ books:
143
+ - id: ddia
144
+ title: Designing Data-Intensive Applications
145
+ author: Martin Kleppmann
146
+ cover: ddia.jpg
147
+ color: "#8b3a2f"
148
+ post: note/ddia
149
+ ```
150
+
151
+ | Field | Required | Description |
152
+ | :--- | :--- | :--- |
153
+ | `title` | Yes | Page title |
154
+ | `description` | No | Page summary |
155
+ | `eyebrow` | No | Small line above the title. Falls back to the site name |
156
+ | `shelves` | Yes | Non-empty list. Each shelf needs `id`, `name`, and a non-empty `books` list |
157
+ | `books[].id` | Yes | Unique across the shelf |
158
+ | `books[].title` | Yes | Title on the left page and the note view |
159
+ | `books[].author` | No | Author |
160
+ | `books[].spine` | No | Spine label. Defaults to `title` |
161
+ | `books[].size` | No | `thin`, `s-short`, `s-mid`, or `s-tall`. Real books default to `s-mid`; filler spines default to `thin` |
162
+ | `books[].cover` | No | File name, or a full URL |
163
+ | `books[].color` | No | Spine and open-page color |
164
+ | `books[].filler` | No | `true`, `sand`, or `cream`. A thin spacer that does not open |
165
+ | `books[].post` | No | Post that supplies the note |
166
+ | `books[].note` | No | Markdown note used only when `post` is absent |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -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 } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } 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,6 +994,22 @@ 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) sitemapEntries.push(pageAddress("/bookshelf/"));
997
1013
  // Card URLs in published entries: the only pages the link-card Worker may read.
998
1014
  fs.writeFileSync(
999
1015
  path.join(publicRoot, "link-allowlist.json"),
@@ -743,13 +743,14 @@ 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);
746
747
  const isFrontmatter = /^\s*---\s*$/mu.test(bodyText) || (/\bdraft:/u.test(bodyText) && /\btags:/u.test(bodyText));
747
748
 
748
749
  if (isWorkflow) {
749
750
  checkWorkflowRunLines(fence, relFile, facts, issues);
750
751
  }
751
752
 
752
- if (!isWorkflow && !isColumnsYaml && !isFrontmatter) {
753
+ if (!isWorkflow && !isColumnsYaml && !isBookshelfYaml && !isFrontmatter) {
753
754
  try {
754
755
  const parsed = parseYaml(bodyText);
755
756
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
@@ -11,6 +11,18 @@ 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
+
14
26
  const trim = (value) => (value.length > 1 ? value.replace(/\/+$/u, "") : value);
15
27
 
16
28
  /**
package/vite.config.ts CHANGED
@@ -14,9 +14,11 @@ 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");
17
18
  const generatedAliases = {
18
19
  "@garden/generated-content": generatedContent,
19
20
  "@garden/site-config": generatedSiteConfig,
21
+ "@garden/bookshelf": generatedBookshelf,
20
22
  };
21
23
 
22
24
  // macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
@@ -67,10 +69,11 @@ function gardenContent() {
67
69
  const normalized = id.split("?")[0];
68
70
  if (normalized === "@garden/generated-content") return generatedContent;
69
71
  if (normalized === "@garden/site-config") return generatedSiteConfig;
72
+ if (normalized === "@garden/bookshelf") return generatedBookshelf;
70
73
  return undefined;
71
74
  },
72
75
  buildStart() {
73
- if (!fs.existsSync(generatedContent) || !fs.existsSync(generatedSiteConfig)) {
76
+ if (!fs.existsSync(generatedContent) || !fs.existsSync(generatedSiteConfig) || !fs.existsSync(generatedBookshelf)) {
74
77
  sync();
75
78
  }
76
79
  },
@@ -78,13 +81,19 @@ function gardenContent() {
78
81
  const contentDir = path.join(siteRoot, "content");
79
82
  const configFile = path.join(siteRoot, "site.config.yaml");
80
83
  server.watcher.add([contentDir, configFile]);
81
- server.watcher.on("change", (file) => {
84
+ const resyncContent = (file: string) => {
82
85
  if (file.startsWith(contentDir) || file === configFile) {
83
86
  // While editing, a broken ui block shows as an error card instead of stopping the server.
84
87
  sync("preview");
85
88
  server.ws.send({ type: "full-reload" });
86
89
  }
87
- });
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); });
88
97
  },
89
98
  };
90
99
  }