hd-garden 0.9.2 → 0.9.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.
@@ -4,9 +4,12 @@ export { Bookshelf } from "./Bookshelf";
4
4
  export { mountBookshelf } from "./engine";
5
5
  export type {
6
6
  BookshelfBook,
7
+ BookshelfBookPost,
7
8
  BookshelfFiller,
8
9
  BookshelfNote,
10
+ BookshelfNoteKind,
9
11
  BookshelfProps,
10
12
  BookshelfShelf,
11
13
  BookshelfSize,
14
+ BookshelfSource,
12
15
  } from "./types";
@@ -7,6 +7,7 @@ export class BookshelfConfigError extends Error {
7
7
  }
8
8
 
9
9
  const SIZES = new Set(["thin", "s-short", "s-mid", "s-tall"]);
10
+ const NOTE_KINDS = new Set(["overview", "excerpt"]);
10
11
 
11
12
  function record(value, code, message) {
12
13
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new BookshelfConfigError(code, message);
@@ -31,6 +32,71 @@ function parseSize(value, where) {
31
32
  throw new BookshelfConfigError("size", `${where} 的 size 只能是 thin、s-short、s-mid 或 s-tall。`);
32
33
  }
33
34
 
35
+ /** Same path identity as the shelf loader: drop content/posts and .md. */
36
+ function postKey(value) {
37
+ return value.trim().replaceAll("\\", "/").replace(/^(?:content\/)?posts\//u, "").replace(/\.md$/u, "");
38
+ }
39
+
40
+ function parsePostEntry(item, where, index) {
41
+ if (typeof item === "string") {
42
+ const path = optionalString(item);
43
+ if (!path) throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 必须是路径。`);
44
+ return { path };
45
+ }
46
+ const entry = record(item, "posts", `${where} 的 posts[${index}] 必须是路径或映射。`);
47
+ const path = optionalString(entry.path);
48
+ if (!path) throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 需要 path。`);
49
+ const kind = optionalString(entry.kind);
50
+ if (kind && !NOTE_KINDS.has(kind)) {
51
+ throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 的 kind 只能是 overview 或 excerpt。`);
52
+ }
53
+ return kind ? { path, kind } : { path };
54
+ }
55
+
56
+ /**
57
+ * `post` is shorthand for one note. `posts` is the list.
58
+ * When `posts` is set it is the list, and `post` may only repeat the first path.
59
+ */
60
+ function parsePostList(book, where) {
61
+ const single = optionalString(book.post);
62
+ if (book.posts == null) {
63
+ return single ? { post: single, posts: [{ path: single }] } : { post: undefined, posts: undefined };
64
+ }
65
+ if (!Array.isArray(book.posts)) {
66
+ throw new BookshelfConfigError("posts", `${where} 的 posts 必须是路径数组。`);
67
+ }
68
+ const posts = book.posts.map((item, index) => parsePostEntry(item, where, index));
69
+ if (posts.length === 0) throw new BookshelfConfigError("posts", `${where} 的 posts 不能为空。`);
70
+ const seenPaths = new Set();
71
+ for (const entry of posts) {
72
+ const key = postKey(entry.path);
73
+ if (seenPaths.has(key)) throw new BookshelfConfigError("posts", `${where} 的 posts 重复引用「${entry.path}」。`);
74
+ seenPaths.add(key);
75
+ }
76
+ if (single && postKey(single) !== postKey(posts[0].path)) {
77
+ throw new BookshelfConfigError("posts", `${where} 的 post 与 posts 的第一篇不是同一路径。多篇笔记只写 posts。`);
78
+ }
79
+ return { post: posts[0].path, posts };
80
+ }
81
+
82
+ function parseSources(value, where) {
83
+ if (value == null) return undefined;
84
+ if (!Array.isArray(value)) throw new BookshelfConfigError("sources", `${where} 的 sources 必须是数组。`);
85
+ if (value.length === 0) return undefined;
86
+ return value.map((item, index) => {
87
+ const source = record(item, "sources", `${where} 的 sources[${index}] 必须是映射。`);
88
+ const label = optionalString(source.label);
89
+ const href = optionalString(source.href);
90
+ if (!label || !href) {
91
+ throw new BookshelfConfigError("sources", `${where} 的 sources[${index}] 需要 label 和 href。`);
92
+ }
93
+ if (!/^(?:https?:\/\/|\/\/|\/(?!\/))/iu.test(href)) {
94
+ throw new BookshelfConfigError("sources", `${where} 的 sources[${index}] 只接受 http(s) 或以 / 开头的地址。`);
95
+ }
96
+ return { label, href };
97
+ });
98
+ }
99
+
34
100
  function parseBook(value, where, seen) {
35
101
  const book = record(value, "book", `${where} 必须是映射。`);
36
102
  const id = optionalString(book.id);
@@ -38,6 +104,7 @@ function parseBook(value, where, seen) {
38
104
  if (!id || !title) throw new BookshelfConfigError("book", `${where} 需要 id 和 title。`);
39
105
  if (seen.has(id)) throw new BookshelfConfigError("duplicate-id", `${where} 的 id「${id}」重复。`);
40
106
  seen.add(id);
107
+ const notes = parsePostList(book, where);
41
108
  return {
42
109
  id,
43
110
  title,
@@ -47,7 +114,9 @@ function parseBook(value, where, seen) {
47
114
  cover: optionalString(book.cover),
48
115
  color: optionalString(book.color),
49
116
  filler: parseFiller(book.filler, where),
50
- post: optionalString(book.post),
117
+ post: notes.post,
118
+ posts: notes.posts,
119
+ sources: parseSources(book.sources, where),
51
120
  note: typeof book.note === "string" && book.note.trim() ? book.note.trim() : undefined,
52
121
  };
53
122
  }
@@ -11,7 +11,9 @@ export type BookshelfConfigCode =
11
11
  | "size"
12
12
  | "filler"
13
13
  | "cover"
14
- | "post";
14
+ | "post"
15
+ | "posts"
16
+ | "sources";
15
17
 
16
18
  export interface BookshelfConfigError extends Error {
17
19
  readonly code: BookshelfConfigCode;
@@ -23,18 +25,54 @@ export const BookshelfConfigError = ConfigError as unknown as new (
23
25
  message: string,
24
26
  ) => BookshelfConfigError;
25
27
 
28
+ /** `overview` is the reading note (总览). `excerpt` is the quotation note (摘抄). */
29
+ export type BookshelfNoteKind = "overview" | "excerpt";
30
+
31
+ /** One note bound to a volume. A string in yaml is shorthand for `{ path }`. */
32
+ export type BookshelfFilePost = {
33
+ /** Path under `content/posts/`, without `.md`. */
34
+ path: string;
35
+ kind?: BookshelfNoteKind;
36
+ };
37
+
38
+ /** Outbound source on the book card. Rendered as a quiet chip, not a button. */
39
+ export type BookshelfFileSource = {
40
+ label: string;
41
+ href: string;
42
+ };
43
+
26
44
  /**
27
- * One volume in `content/bookshelf.yaml`.
45
+ * One volume in `content/bookshelf.yaml`. One yaml entry is one spine.
46
+ *
47
+ * Covers stay on two paths:
48
+ * - yaml `cover` is the book jacket (Douban). It is the only image for the
49
+ * shelf face, the open/close flyer, and the book-hub card.
50
+ * - a post's front matter `cover` is that note's hero on the article page.
51
+ * Never copy a note hero into the jacket slot.
52
+ * - omit yaml `cover` and the jacket falls back to the first note's cover.
28
53
  *
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.
54
+ * Notes:
55
+ * - `post: path` is shorthand for `posts: [path]`.
56
+ * - `posts` is the note list, in order. `kind: overview | excerpt` labels the
57
+ * hub card 总览 or 摘抄. A bare string is a path with no kind.
58
+ * - when `posts` is set, `post` is optional. If both are set, `post` must be
59
+ * the same path as `posts[0]`. Otherwise the shelf is rejected.
60
+ * - one note: the shelf opens that note as it does today (extract flyer, note,
61
+ * return flyer). There is no book hub.
62
+ * - two or more notes: a spine click plays the same extract flyer and lands
63
+ * on `/bookshelf/<id>` (本书). It does not open `posts[0]`.
64
+ * The hub card is the locked book-multi layout: the jacket slot is 148px
65
+ * wide, the image fills that width, and the height follows the file's ratio.
66
+ * There is no plate behind the jacket. A Douban scan's white border is
67
+ * cropped in the jacket file before it is committed; the build copies that
68
+ * file into `/media/covers/` unchanged.
69
+ * 「我的笔记」 lists the notes. A note card is an ordinary link to that
70
+ * article; article ↔ list does not play the flyer. Returning from the hub
71
+ * to the shelf plays the close flyer. A single note uses that same flyer.
72
+ * - `sources` are optional `{ label, href }` chips. The hub shows 豆瓣 and PDF
73
+ * only. The PDF chip carries the caption 「(仅分享,侵权删)」. No chapter list.
74
+ * - `note` is Markdown used only when the book has no posts.
36
75
  * - `color` (`#rgb` or `#rrggbb`) is the spine cloth and the open-page palette.
37
- * - `note` is Markdown used only when `post` is absent.
38
76
  */
39
77
  export type BookshelfFileBook = {
40
78
  id: string;
@@ -42,10 +80,14 @@ export type BookshelfFileBook = {
42
80
  author?: string;
43
81
  spine?: string;
44
82
  size?: BookshelfSize;
83
+ /** Book jacket. File name under `content/covers/`, or a full URL. */
45
84
  cover?: string;
46
85
  color?: string;
47
86
  filler?: BookshelfFiller;
87
+ /** Shorthand for a single note. Equal to `posts[0].path` after parsing. */
48
88
  post?: string;
89
+ posts?: BookshelfFilePost[];
90
+ sources?: BookshelfFileSource[];
49
91
  note?: string;
50
92
  };
51
93
 
@@ -2,12 +2,38 @@ import type { BookshelfFiller, BookshelfSize } from "./model";
2
2
 
3
3
  export type { BookshelfFiller, BookshelfSize };
4
4
 
5
+ /** `overview` prints as 总览 on the hub. `excerpt` prints as 摘抄. */
6
+ export type BookshelfNoteKind = "overview" | "excerpt";
7
+
8
+ /** One reading note on a volume. Order is the yaml order. */
9
+ export type BookshelfBookPost = {
10
+ /** `content/posts` path without `.md`. */
11
+ path: string;
12
+ title: string;
13
+ kind?: BookshelfNoteKind;
14
+ /** Front matter description. The hub card summary. */
15
+ summary?: string;
16
+ date?: string;
17
+ /** Same count the article page shows. */
18
+ words?: number;
19
+ /** Article address. The shelf page fills this. */
20
+ href?: string;
21
+ };
22
+
23
+ /** Quiet source chip on the book hub (豆瓣, PDF). */
24
+ export type BookshelfSource = {
25
+ label: string;
26
+ href: string;
27
+ };
28
+
5
29
  /**
6
30
  * One volume on the shelf.
31
+ * `cover` is the book jacket only: shelf face, open/close flyer, and the hub card.
7
32
  * `cover: true` uses the package-demo jacket at `/covers/<id>.jpg`.
8
33
  * A string is a cover URL (`/media/covers/…` for a site shelf, or any absolute URL).
9
34
  * 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.
35
+ * A note's own hero stays on the article page. It is never this field when yaml `cover` is set.
36
+ * `color` is the spine cloth and the open-page palette. Sample it from the jacket.
11
37
  */
12
38
  export type BookshelfBook = {
13
39
  id: string;
@@ -18,10 +44,18 @@ export type BookshelfBook = {
18
44
  cover?: boolean | string;
19
45
  color?: string;
20
46
  filler?: BookshelfFiller;
21
- /** Key into the notes map. Null means the demo placeholder paragraph. */
47
+ /**
48
+ * Key into the notes map for a single-note book.
49
+ * Null on a multi-note book: that book opens `/bookshelf/<id>` instead.
50
+ */
22
51
  note?: string | null;
23
- /** `content/posts` path without `.md`, when the note is a post. */
52
+ /** Primary note path. Set only when the book has one note. */
24
53
  post?: string;
54
+ /** Every note on this volume, primary first. */
55
+ posts?: BookshelfBookPost[];
56
+ /** `/bookshelf/<id>`. Set only when `posts` has two or more notes. */
57
+ hub?: string;
58
+ sources?: BookshelfSource[];
25
59
  /** Full-note address, shaped for this build. Set when `post` is set. */
26
60
  href?: string;
27
61
  };
@@ -52,4 +86,9 @@ export type BookshelfProps = {
52
86
  * unset and keeps its own full-viewport frame.
53
87
  */
54
88
  embedded?: boolean;
89
+ /**
90
+ * Open `/bookshelf/<id>` already on the hub, with the shelf parked under it
91
+ * so the return flyer can run. A shelf click does not use this.
92
+ */
93
+ initialHub?: string;
55
94
  };
package/app/lib/i18n.ts CHANGED
@@ -299,6 +299,11 @@ const zh = {
299
299
  adminTitlePreview: "草稿预览",
300
300
  a2IndexTitle: "A2 对照",
301
301
  a2IndexDeck: "对照参考文章的版式。正文没有改字。",
302
+ bookHubCurrent: "本书",
303
+ bookHubNotes: "我的笔记",
304
+ bookHubOverview: "总览",
305
+ bookHubExcerpt: "摘抄",
306
+ bookHubPdfNote: "(仅分享,侵权删)",
302
307
  };
303
308
 
304
309
  const en: typeof zh = {
@@ -582,6 +587,11 @@ const en: typeof zh = {
582
587
  adminTitlePreview: "Draft Preview",
583
588
  a2IndexTitle: "A2 Parity",
584
589
  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)",
585
595
  };
586
596
 
587
597
  export const UI_STRINGS = { zh, en } as const;
package/app/sitemap.ts CHANGED
@@ -4,6 +4,7 @@ import { columnHref, columns, postHref, posts, thoughtHref } from "./lib/content
4
4
  import { absoluteUrl, getSiteUrl } from "./lib/site";
5
5
  import { getPublishedThoughts } from "./lib/public-thoughts";
6
6
  import { siteHref } from "./lib/site-href";
7
+ import type { BookshelfBook, BookshelfShelf } from "./components/bookshelf/types";
7
8
 
8
9
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
9
10
  const baseUrl = await getSiteUrl();
@@ -17,6 +18,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
17
18
  { path: "/garden", priority: 0.4, changeFrequency: "monthly" as const },
18
19
  // Only a real shelf is a page. The package demo stays out of the sitemap.
19
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
+ : []),
20
28
  ].map((route) => ({
21
29
  url: absoluteUrl(siteHref(route.path), baseUrl),
22
30
  changeFrequency: route.changeFrequency,
@@ -434,6 +434,43 @@ 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
+
437
474
  .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}
438
475
  @media (hover:hover){.sh-row .incol:hover{color:var(--blue);text-decoration:underline;text-underline-offset:3px}}
439
476
  .is-compact .sh-row .incol{display:none}
@@ -130,7 +130,9 @@ columns:
130
130
 
131
131
  `/bookshelf` 在站点写了 `content/bookshelf.yaml` 时渲染这里的书,并带上站点顶栏,和其他页面一样可以被索引。没有这个文件时,`/bookshelf` 只在引擎包内的中文验收里显示内置 demo(无顶栏、不收录);正式站要真书目,就放下这份文件。
132
132
 
133
- 每本书的 `post` 是 `content/posts/` 下的路径,不带 `.md`。打开后的左页是书名,右页是这篇的摘要(front matter 的 `description`),笔记页是正文。封面写文件名时,地址是 `/media/covers/<文件名>`,和博文 front matter 的 `cover` 一致,文件放在 `content/covers/`。已经写了 `https://…`,或以 `/` 开头的地址,则原样使用。不写 `cover` 时,沿用这篇博文自己的封面。`color` 取封面的主色(`#rgb` 或 `#rrggbb`),用来画书脊和打开后的纸页。
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`)画书脊和单篇打开后的纸页。
134
136
 
135
137
  ```yaml
136
138
  title: 读书笔记
@@ -146,6 +148,20 @@ shelves:
146
148
  cover: ddia.jpg
147
149
  color: "#8b3a2f"
148
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
149
165
  ```
150
166
 
151
167
  | 字段 | 必填 | 说明 |
@@ -159,8 +175,10 @@ shelves:
159
175
  | `books[].author` | 否 | 作者 |
160
176
  | `books[].spine` | 否 | 书脊文字,默认同 `title` |
161
177
  | `books[].size` | 否 | `thin`、`s-short`、`s-mid`、`s-tall`。普通书默认 `s-mid`,占位薄册默认 `thin` |
162
- | `books[].cover` | 否 | 文件名,或完整 URL |
163
- | `books[].color` | 否 | 书脊和打开后的纸色 |
178
+ | `books[].cover` | 否 | 书衣。文件名或完整 URL。写了就只用它 |
179
+ | `books[].color` | 否 | 书脊和单篇打开后的纸色 |
164
180
  | `books[].filler` | 否 | `true`、`sand` 或 `cream`,占位薄册,不能打开 |
165
- | `books[].post` | 否 | 笔记正文所在的博文 |
166
- | `books[].note` | 否 | 没有 `post` 时直接写的 Markdown 笔记 |
181
+ | `books[].post` | 否 | 单篇笔记的简写 |
182
+ | `books[].posts` | 否 | 同一本书下的笔记。`kind` 为 `overview`(总览)或 `excerpt`(摘抄)。两篇及以上打开本书页 |
183
+ | `books[].sources` | 否 | 出处。本书页只显示「豆瓣」和「PDF」 |
184
+ | `books[].note` | 否 | 没有笔记路径时直接写的 Markdown |
@@ -130,7 +130,9 @@ columns:
130
130
 
131
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
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.
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.
134
136
 
135
137
  ```yaml
136
138
  title: Reading notes
@@ -146,6 +148,20 @@ shelves:
146
148
  cover: ddia.jpg
147
149
  color: "#8b3a2f"
148
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
149
165
  ```
150
166
 
151
167
  | Field | Required | Description |
@@ -159,8 +175,10 @@ shelves:
159
175
  | `books[].author` | No | Author |
160
176
  | `books[].spine` | No | Spine label. Defaults to `title` |
161
177
  | `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 |
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 |
164
180
  | `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 |
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.2",
3
+ "version": "0.9.4",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveBookshelfCover } from "../app/components/bookshelf/catalog.mjs";
4
+ import { BookshelfConfigError } from "../app/components/bookshelf/schema.mjs";
5
+
6
+ /**
7
+ * Where a `books[].cover` value sits under `content/covers/`.
8
+ * Full URLs are not local files. `/media/covers/…` is the published name of a file in that directory.
9
+ * Returns null when there is nothing to copy.
10
+ */
11
+ export function bookshelfJacketSource(cover) {
12
+ const value = String(cover ?? "").trim();
13
+ if (!value) return null;
14
+ if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//iu.test(value) || value.startsWith("data:")) return null;
15
+ if (value.startsWith("/")) {
16
+ const marker = "/media/covers/";
17
+ const index = value.indexOf(marker);
18
+ if (index < 0) return null;
19
+ return safeCoverRelative(decodeURIComponent(value.slice(index + marker.length)).split(/[?#]/u)[0], value);
20
+ }
21
+ let relative = value.replace(/^\.\//u, "").replaceAll("\\", "/").split(/[?#]/u)[0];
22
+ if (relative.startsWith("covers/")) relative = relative.slice("covers/".length);
23
+ return safeCoverRelative(relative, value);
24
+ }
25
+
26
+ function safeCoverRelative(relative, original) {
27
+ if (!relative || relative.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
28
+ throw new BookshelfConfigError("cover", `书衣 ${original} 必须是 content/covers 下的文件。`);
29
+ }
30
+ return relative;
31
+ }
32
+
33
+ /**
34
+ * Copy every local book jacket into `public/media/covers/`, including filenames
35
+ * that no post front matter mentions. Bytes are copied as stored (white borders
36
+ * are cropped in the file itself, not here).
37
+ */
38
+ export function publishBookshelfJackets(config, { coversDir, mediaDir }) {
39
+ const copied = [];
40
+ for (const shelf of config.shelves ?? []) {
41
+ for (const book of shelf.books ?? []) {
42
+ if (!book.cover) continue;
43
+ const sourceRel = bookshelfJacketSource(book.cover);
44
+ if (!sourceRel) continue;
45
+ const published = resolveBookshelfCover(book.cover, book.id);
46
+ if (!published?.startsWith("/media/covers/")) continue;
47
+ const publicRel = decodeURIComponent(published.slice("/media/covers/".length)).split(/[?#]/u)[0];
48
+ const source = path.join(coversDir, ...sourceRel.split("/"));
49
+ const destination = path.join(mediaDir, ...publicRel.split("/"));
50
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
51
+ throw new BookshelfConfigError(
52
+ "cover",
53
+ `content/covers 里没有书衣 ${sourceRel}。bookshelf.yaml 的 cover 会原样复制到 /media/covers/。`,
54
+ );
55
+ }
56
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
57
+ fs.copyFileSync(source, destination);
58
+ copied.push(publicRel);
59
+ }
60
+ }
61
+ return copied;
62
+ }
@@ -1009,7 +1009,21 @@ fs.writeFileSync(
1009
1009
  path.join(gardenDir, "bookshelf.ts"),
1010
1010
  `// Generated by garden sync. Edit content/bookshelf.yaml instead.\nexport const bookshelf = ${JSON.stringify(bookshelf)} as const;\n`,
1011
1011
  );
1012
- if (bookshelf) sitemapEntries.push(pageAddress("/bookshelf/"));
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
+ }
1013
1027
  // Card URLs in published entries: the only pages the link-card Worker may read.
1014
1028
  fs.writeFileSync(
1015
1029
  path.join(publicRoot, "link-allowlist.json"),