hd-garden 0.9.1 → 0.9.3

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,55 @@
1
+ import type { Metadata } from "next";
2
+ import { notFound } from "next/navigation";
3
+ import { bookshelf } from "@garden/bookshelf";
4
+ import { BookHub } from "../../components/bookshelf/BookHub";
5
+ import { pageAlternates } from "../../lib/page-alternates";
6
+ import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
7
+ import type { BookshelfBook } from "../../components/bookshelf/types";
8
+
9
+ function hubBooks(): { shelfTitle: string; book: BookshelfBook }[] {
10
+ if (!bookshelf) return [];
11
+ const found: { shelfTitle: string; book: BookshelfBook }[] = [];
12
+ for (const row of bookshelf.catalog) {
13
+ for (const book of row.books) {
14
+ if ((book.posts?.length ?? 0) > 1) found.push({ shelfTitle: bookshelf.title, book });
15
+ }
16
+ }
17
+ return found;
18
+ }
19
+
20
+ export function generateStaticParams() {
21
+ return staticParams(
22
+ hubBooks().map(({ book }) => ({ bookId: book.id })),
23
+ { bookId: STATIC_PLACEHOLDER },
24
+ );
25
+ }
26
+
27
+ export async function generateMetadata({
28
+ params,
29
+ }: {
30
+ params: Promise<{ bookId: string }>;
31
+ }): Promise<Metadata> {
32
+ const { bookId } = await params;
33
+ const found = hubBooks().find((item) => item.book.id === bookId);
34
+ if (!found) return {};
35
+ return {
36
+ title: found.book.title,
37
+ description: found.book.posts?.find((post) => post.summary)?.summary,
38
+ alternates: pageAlternates(`/bookshelf/${found.book.id}`),
39
+ };
40
+ }
41
+
42
+ /** `/bookshelf/<id>` — one multi-note volume. Single-note books stay on the shelf. */
43
+ export default async function BookHubPage({
44
+ params,
45
+ }: {
46
+ params: Promise<{ bookId: string }>;
47
+ }) {
48
+ const { bookId } = await params;
49
+ const found = hubBooks().find((item) => item.book.id === bookId);
50
+ if (!found) {
51
+ if (isStaticPlaceholder(bookId)) return null;
52
+ notFound();
53
+ }
54
+ return <BookHub shelfTitle={found.shelfTitle} book={found.book} />;
55
+ }
@@ -13,7 +13,8 @@ export function bookshelfConfigPath(root = gardenSiteRoot()) {
13
13
  /**
14
14
  * The site shelf, or null when `content/bookshelf.yaml` is absent.
15
15
  * Absent means the package demo (click-test only). A file here is the live shelf:
16
- * each book's `post` is read from `content/posts` and turned into note HTML.
16
+ * each book's `post` or `posts` is read from `content/posts`. One note becomes
17
+ * the open-book HTML. Several notes stay on the book for `/bookshelf/<id>`.
17
18
  * `preparedPosts`, when passed, are the sync pipeline's posts (rewritten asset
18
19
  * URLs). Otherwise the markdown files are read directly.
19
20
  * Throws `BookshelfConfigError` when the file, a cover, or a post path is broken.
@@ -35,22 +36,33 @@ export function loadBookshelf(root = gardenSiteRoot(), preparedPosts) {
35
36
  title: post.title,
36
37
  description: post.description,
37
38
  cover: post.cover,
39
+ date: typeof post.date === "string" ? post.date : undefined,
38
40
  content: post.content,
39
41
  }))
40
42
  : readReferencedPosts(root, config);
41
43
  return bookshelfMountFromFile(config, sources);
42
44
  }
43
45
 
46
+ function referencedPostPaths(book) {
47
+ if (Array.isArray(book.posts) && book.posts.length > 0) {
48
+ return book.posts
49
+ .map((entry) => (typeof entry === "string" ? entry : entry?.path))
50
+ .filter((path) => typeof path === "string" && path.trim());
51
+ }
52
+ return book.post ? [book.post] : [];
53
+ }
54
+
44
55
  function readReferencedPosts(root, config) {
45
56
  const seen = new Set();
46
57
  const posts = [];
47
58
  for (const shelf of config.shelves) {
48
59
  for (const book of shelf.books) {
49
- if (!book.post) continue;
50
- const relative = normalizeBookshelfPostPath(book.post);
51
- if (seen.has(relative)) continue;
52
- seen.add(relative);
53
- posts.push(readPost(root, relative));
60
+ for (const entry of referencedPostPaths(book)) {
61
+ const relative = normalizeBookshelfPostPath(entry);
62
+ if (seen.has(relative)) continue;
63
+ seen.add(relative);
64
+ posts.push(readPost(root, relative));
65
+ }
54
66
  }
55
67
  }
56
68
  return posts;
@@ -85,6 +97,7 @@ function readPost(root, postPath) {
85
97
  title: optionalString(data.title),
86
98
  description: optionalString(data.description),
87
99
  cover: optionalString(data.cover),
100
+ date: optionalDate(data.date),
88
101
  content: parsed.content,
89
102
  };
90
103
  }
@@ -94,3 +107,8 @@ function optionalString(value) {
94
107
  const trimmed = value.trim();
95
108
  return trimmed ? trimmed : undefined;
96
109
  }
110
+
111
+ function optionalDate(value) {
112
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
113
+ return optionalString(value);
114
+ }
@@ -0,0 +1,142 @@
1
+ import Link from "next/link";
2
+ import { siteHref } from "../../lib/site-href";
3
+ import { ui } from "../../lib/i18n";
4
+ import type { BookshelfBook, BookshelfBookPost, BookshelfNoteKind, BookshelfSource } from "./types";
5
+
6
+ function day(value: string) {
7
+ const date = new Date(value);
8
+ if (Number.isNaN(date.getTime())) return value.slice(0, 10);
9
+ return new Intl.DateTimeFormat("en-CA", {
10
+ timeZone: "Asia/Shanghai",
11
+ year: "numeric",
12
+ month: "2-digit",
13
+ day: "2-digit",
14
+ }).format(date);
15
+ }
16
+
17
+ function kindLabel(kind: BookshelfNoteKind | undefined) {
18
+ const t = ui();
19
+ switch (kind) {
20
+ case "overview":
21
+ return t.bookHubOverview;
22
+ case "excerpt":
23
+ return t.bookHubExcerpt;
24
+ case undefined:
25
+ return "";
26
+ default: {
27
+ const unexpected: never = kind;
28
+ return unexpected;
29
+ }
30
+ }
31
+ }
32
+
33
+ function sourceHref(href: string) {
34
+ if (href.startsWith("/") && !href.startsWith("//")) return siteHref(href);
35
+ return href;
36
+ }
37
+
38
+ function isExternal(href: string) {
39
+ return /^(?:https?:)?\/\//iu.test(href);
40
+ }
41
+
42
+ /** Hub chips are Douban and PDF. The Chinese label is the zh shelf; English sites write Douban. */
43
+ function isDoubanChip(label: string) {
44
+ return label === "豆瓣" || /^douban$/iu.test(label);
45
+ }
46
+
47
+ function hubSources(sources: readonly BookshelfSource[] | undefined) {
48
+ const list = sources ?? [];
49
+ const douban = list.filter((source) => isDoubanChip(source.label));
50
+ const pdf = list.filter((source) => /pdf/iu.test(source.label));
51
+ return [...douban, ...pdf];
52
+ }
53
+
54
+ function NoteCard({ entry }: { entry: BookshelfBookPost }) {
55
+ const t = ui();
56
+ const label = kindLabel(entry.kind);
57
+ const href = entry.href ?? siteHref(`/blog/${entry.path}`);
58
+ const when = entry.date ? day(entry.date) : "";
59
+ const count = entry.words ? t.wordCount(entry.words) : "";
60
+ return (
61
+ <Link className="book-hub-note" href={href}>
62
+ {label ? <p className="book-hub-note-label">{label}</p> : null}
63
+ <h3>{entry.title}</h3>
64
+ {entry.summary ? <p className="book-hub-note-sum">{entry.summary}</p> : null}
65
+ {when || count ? (
66
+ <p className="book-hub-note-meta">
67
+ {when}
68
+ {when && count ? " · " : null}
69
+ {count}
70
+ </p>
71
+ ) : null}
72
+ </Link>
73
+ );
74
+ }
75
+
76
+ function SourceChips({ sources }: { sources: readonly BookshelfSource[] }) {
77
+ const shown = hubSources(sources);
78
+ if (!shown.length) return null;
79
+ const t = ui();
80
+ return (
81
+ <div className="book-hub-sources">
82
+ {shown.map((source) => {
83
+ const href = sourceHref(source.href);
84
+ const external = isExternal(source.href);
85
+ const pdf = /pdf/iu.test(source.label);
86
+ return (
87
+ <span className="book-hub-source" key={`${source.label}\u0000${source.href}`}>
88
+ <a
89
+ className="book-hub-chip"
90
+ href={href}
91
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
92
+ >
93
+ {source.label}
94
+ <span className="ext" aria-hidden="true">↗</span>
95
+ </a>
96
+ {pdf ? <span className="book-hub-source-note">{t.bookHubPdfNote}</span> : null}
97
+ </span>
98
+ );
99
+ })}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /** Book hub (本书). Multi-note volumes land here; a note card is a plain article link. */
105
+ export function BookHub({ shelfTitle, book }: { shelfTitle: string; book: BookshelfBook }) {
106
+ const t = ui();
107
+ const jacket = typeof book.cover === "string" ? book.cover : "";
108
+ const posts = book.posts ?? [];
109
+ return (
110
+ <main className="sh-main book-hub" id="content">
111
+ <p className="book-hub-crumb">
112
+ <Link href={siteHref("/columns")}>{t.homeColumns}</Link>
113
+ <span aria-hidden="true"> · </span>
114
+ <Link href={siteHref("/bookshelf")}>{shelfTitle}</Link>
115
+ <span aria-hidden="true"> · </span>
116
+ <span aria-current="page">{t.bookHubCurrent}</span>
117
+ </p>
118
+
119
+ <section className="book-hub-card" aria-labelledby="book-hub-title">
120
+ {jacket ? (
121
+ <div className="book-hub-cover">
122
+ <img src={jacket} alt={t.coverLabel(book.title)} width={148} />
123
+ </div>
124
+ ) : null}
125
+ <div className="book-hub-meta">
126
+ <h1 id="book-hub-title">{book.title}</h1>
127
+ {book.author ? <p className="book-hub-author">{book.author}</p> : null}
128
+ <SourceChips sources={book.sources ?? []} />
129
+ </div>
130
+ </section>
131
+
132
+ <h2 className="book-hub-notes-head">{t.bookHubNotes}</h2>
133
+ <ul className="book-hub-notes">
134
+ {posts.map((entry) => (
135
+ <li key={entry.path}>
136
+ <NoteCard entry={entry} />
137
+ </li>
138
+ ))}
139
+ </ul>
140
+ </main>
141
+ );
142
+ }
@@ -12,9 +12,20 @@ function withNoteLinks(catalog: BookshelfShelf[] | undefined) {
12
12
  if (!catalog) return catalog;
13
13
  return catalog.map((row) => ({
14
14
  ...row,
15
- books: row.books.map((book) =>
16
- book.post ? { ...book, href: siteHref(`/blog/${book.post}`) } : book,
17
- ),
15
+ books: row.books.map((book) => {
16
+ const posts = book.posts?.map((entry) => ({
17
+ ...entry,
18
+ href: siteHref(`/blog/${entry.path}`),
19
+ }));
20
+ const hub = book.hub ? siteHref(book.hub) : undefined;
21
+ if (!book.post && !posts?.length && !hub) return book;
22
+ return {
23
+ ...book,
24
+ ...(book.post ? { href: siteHref(`/blog/${book.post}`) } : {}),
25
+ ...(posts?.length ? { posts } : {}),
26
+ ...(hub ? { hub } : {}),
27
+ };
28
+ }),
18
29
  }));
19
30
  }
20
31
 
@@ -235,18 +235,22 @@
235
235
  z-index: 2;
236
236
  }
237
237
  }
238
- .bookshelf-demo .cover.is-placeholder {
239
- /* ux13+ux24: invisible seat — no gray/wood/bk plate while flyer is out */
238
+ .bookshelf-demo .cover.is-placeholder,
239
+ .bookshelf-demo .spine.is-placeholder {
240
+ /* Empty seat while the flyer owns the motion: shelf surface only.
241
+ A bk-* fill here is the gray/lavender plate behind the real cover. */
240
242
  opacity: 0 !important;
241
243
  visibility: hidden !important;
242
244
  background-color: transparent !important;
243
245
  background-image: none !important;
244
- filter: none;
246
+ filter: none !important;
245
247
  pointer-events: none;
246
- box-shadow: none;
248
+ box-shadow: none !important;
249
+ color: transparent !important;
247
250
  transform: none !important;
248
251
  }
249
- .bookshelf-demo .cover.is-placeholder > .cover-art {
252
+ .bookshelf-demo .cover.is-placeholder > .cover-art,
253
+ .bookshelf-demo .spine.is-placeholder > .cover-art {
250
254
  visibility: hidden !important;
251
255
  opacity: 0 !important;
252
256
  }
@@ -342,15 +346,7 @@
342
346
  z-index: 2;
343
347
  }
344
348
  }
345
- .bookshelf-demo .spine.is-placeholder {
346
- /* ux13: invisible seat — ban gray ghost plate */
347
- opacity: 0;
348
- filter: none;
349
- pointer-events: none;
350
- box-shadow: none;
351
- transform: none !important;
352
- }
353
- .bookshelf-demo .spine.is-placeholder span { opacity: 0; }
349
+ .bookshelf-demo .spine.is-placeholder span { opacity: 0 !important; visibility: hidden !important; }
354
350
  .bookshelf-demo .cover.is-placeholder.is-landing,
355
351
  .bookshelf-demo .spine.is-placeholder.is-landing {
356
352
  opacity: 0;
@@ -359,6 +355,25 @@
359
355
 
360
356
  /* Mode C: temporary expand on spine wall */
361
357
  /* resting spines: solid hue only; cover image only when expanded */
358
+ /* Expanded / extracting face must not fall back to bk-* (#513688 etc.).
359
+ The jacket is .cover-art. A missing image stays cream shelf, not a shell pad. */
360
+ .bookshelf-demo .spine.is-expanded,
361
+ .bookshelf-demo .spine.is-extracting {
362
+ background-color: transparent !important;
363
+ }
364
+ .bookshelf-demo .spine.is-expanded > .cover-art,
365
+ .bookshelf-demo .spine.is-extracting > .cover-art {
366
+ position: absolute;
367
+ inset: 0;
368
+ width: 100%;
369
+ height: 100%;
370
+ object-fit: contain;
371
+ object-position: center top;
372
+ display: block;
373
+ pointer-events: none;
374
+ z-index: 1;
375
+ border-radius: inherit;
376
+ }
362
377
  .bookshelf-demo .spine[class*="img-"] {
363
378
  background-image: none !important;
364
379
  }
@@ -559,15 +574,32 @@
559
574
  align-items: flex-start;
560
575
  margin-bottom: 36px;
561
576
  }
577
+ .bookshelf-demo .note-meta {
578
+ min-width: 0;
579
+ flex: 1 1 auto;
580
+ }
581
+ /* Settled note header: size the box to the picture. A fixed 2:3 frame with
582
+ background-size:cover center-crops landscape heroes (Obsidian → "sid"). */
562
583
  .bookshelf-demo .note-cover {
563
- width: 120px;
564
- height: 180px;
565
- flex-shrink: 0;
584
+ width: max-content;
585
+ max-width: min(420px, 46%);
586
+ height: auto;
587
+ flex: 0 0 auto;
566
588
  border-radius: 2px 3px 0 0;
567
- background-size: cover;
568
- background-position: center top;
569
- background-color: #2a2a2a;
589
+ background-color: transparent;
590
+ background-image: none;
570
591
  box-shadow: 0 8px 20px rgba(40,30,20,0.12);
592
+ line-height: 0;
593
+ }
594
+ .bookshelf-demo .note-cover-img {
595
+ display: block;
596
+ width: auto;
597
+ height: auto;
598
+ max-width: 100%;
599
+ max-height: 180px;
600
+ object-fit: contain;
601
+ object-position: center;
602
+ border-radius: inherit;
571
603
  }
572
604
  .bookshelf-demo .note-cat {
573
605
  margin: 0 0 8px;
@@ -713,21 +745,16 @@
713
745
  backface-visibility: hidden;
714
746
  }
715
747
 
716
- /* Thin cover board peeking under page block (thickness cue) */
748
+ /* ux27 plate ban: this used to be a full-spread shell (fallback #6a7a98 / #5a6a88,
749
+ and shell-hi of dark covers reads the same gray-lavender). It sat wider than
750
+ the jacket during lift and put-back. Fore-edge stays on .flyer-thickness. */
717
751
  .bookshelf-demo .flyer-board {
718
- position: absolute;
719
- inset: -2% -1.2% -3.5% -1.2%;
720
- border-radius: 3px 4px 2px 2px;
721
- /* ux26: shell = this book's cover dominant (via --shell); ban site-wide brown */
722
- background: linear-gradient(
723
- 180deg,
724
- var(--shell-hi, #6a7a98) 0%,
725
- var(--shell, #5a6a88) 55%,
726
- var(--shell-lo, #3a4a68) 100%
727
- );
728
- box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset;
729
- transform: translateZ(-8px);
730
- z-index: 0;
752
+ display: none !important;
753
+ background: none !important;
754
+ background-color: transparent !important;
755
+ box-shadow: none !important;
756
+ opacity: 0 !important;
757
+ visibility: hidden !important;
731
758
  }
732
759
 
733
760
  .bookshelf-demo .flyer-leaf {
@@ -759,7 +786,7 @@
759
786
  /* Cover on BACK of left leaf — camera-facing when leaf folded (180°) */
760
787
  .bookshelf-demo .flyer-face-cover {
761
788
  transform: rotateY(180deg);
762
- background-color: transparent; /* ux24: no preset wood plate */
789
+ background-color: transparent !important; /* ux24/ux27: bk-* must not fill behind the img */
763
790
  /* No background-image here — .img-* / inline applyCoverImage owns the photo.
764
791
  Gradients-only used to override img classes → flat brown/gray close flash. */
765
792
  box-shadow:
@@ -782,17 +809,18 @@
782
809
  }
783
810
  .bookshelf-demo .flyer-spine-label { pointer-events: none; }
784
811
 
785
- /* ux15 plate kept inert; ux16 uses full-height .flyer-spine-slot instead */
812
+ /* ux15 plate kept inert; ux16 uses full-height .flyer-spine-slot instead.
813
+ Never paint the lavender shell fallback if a leftover plate node is mounted. */
786
814
  .bookshelf-demo .flyer-spine-land-plate {
787
815
  position: absolute;
788
816
  inset: 0;
789
817
  pointer-events: none;
790
818
  border-radius: inherit;
791
819
  z-index: 1;
792
- display: none;
793
- }
794
- .bookshelf-demo .flyer-spine-land-plate:not([class*="bk-"]) {
795
- background: linear-gradient(180deg, var(--shell-hi, #6a7a98) 0%, var(--shell, #5a6a88) 100%);
820
+ display: none !important;
821
+ background: none !important;
822
+ background-color: transparent !important;
823
+ opacity: 0 !important;
796
824
  }
797
825
 
798
826
  /* ux16: exact homeRect standing spine — full height, spine width, slides out/in */
@@ -1002,6 +1030,18 @@
1002
1030
  overflow: auto;
1003
1031
  }
1004
1032
 
1033
+ /* Close: flyer owns the motion. The note used to stay at z-index 75, so the
1034
+ article sat on top of the return for the whole fade and the shrink was hidden. */
1035
+ .bookshelf-demo.is-return .stage { z-index: 80; }
1036
+ .bookshelf-demo.is-return .note-view,
1037
+ .bookshelf-demo.is-return .note-view.is-preparing,
1038
+ .bookshelf-demo.is-return .note-view.is-open { z-index: 60; }
1039
+ .bookshelf-demo.is-site.is-return .stage { z-index: 36; }
1040
+ .bookshelf-demo.is-site.is-return .open-backdrop { z-index: 30; }
1041
+ .bookshelf-demo.is-site.is-return .note-view,
1042
+ .bookshelf-demo.is-site.is-return .note-view.is-preparing,
1043
+ .bookshelf-demo.is-site.is-return .note-view.is-open { z-index: 31; }
1044
+
1005
1045
  .bookshelf-demo .shelf-view.is-dim {
1006
1046
  /* Backdrop covers shelf — dim is unused during open; keep inert */
1007
1047
  opacity: 1;
@@ -1059,7 +1099,7 @@
1059
1099
  inset: 0;
1060
1100
  border-radius: inherit;
1061
1101
  overflow: hidden;
1062
- background-color: transparent; /* ux24: no preset wood plate */
1102
+ background-color: transparent !important; /* ux24/ux27: no solid shell behind the img */
1063
1103
  background-size: cover;
1064
1104
  background-position: center top;
1065
1105
  background-repeat: no-repeat;
@@ -1162,7 +1202,13 @@
1162
1202
  .bookshelf-demo .spine.is-expanded.is-collapsing.s-mid { width: 24px; height: 134px; }
1163
1203
  .bookshelf-demo .spine.is-expanded.is-collapsing.s-short { width: 20px; height: 120px; }
1164
1204
  .bookshelf-demo .note-hero { flex-direction: column; gap: 16px; }
1165
- .bookshelf-demo .note-cover { width: 100px; height: 150px; }
1205
+ .bookshelf-demo .note-cover { width: max-content; max-width: 100%; }
1206
+ .bookshelf-demo .note-cover-img {
1207
+ width: auto;
1208
+ max-width: 100%;
1209
+ max-height: 180px;
1210
+ height: auto;
1211
+ }
1166
1212
  .bookshelf-demo .qa-hint { width: 100%; margin-left: 0; margin-top: 4px; }
1167
1213
  }
1168
1214
 
@@ -1,4 +1,5 @@
1
1
  import { marked } from "marked";
2
+ import { searchableText } from "../../../ui/garden/search-index.mjs";
2
3
  import { BookshelfConfigError } from "./schema.mjs";
3
4
 
4
5
  /**
@@ -6,10 +7,13 @@ import { BookshelfConfigError } from "./schema.mjs";
6
7
  * Built only from `content/bookshelf.yaml` and the posts it names.
7
8
  * The package demo is a separate path: this module never reads it.
8
9
  *
9
- * `post` is a `content/posts` path without `.md`. Its front matter description
10
- * leads the note HTML so the open book's right page is that summary.
11
- * A cover file name is published at `/media/covers/<file>` (same stem cleanup
12
- * as content sync). A full URL or a path starting with `/` is kept.
10
+ * `post` is shorthand for one note. `posts` is the list. A single note still
11
+ * becomes the open-book body. Two or more notes do not: the book carries `hub`
12
+ * (`/bookshelf/<id>`) and the shelf navigates there instead of opening `posts[0]`.
13
+ *
14
+ * The jacket is yaml `cover` only. A post cover is that note's hero and is
15
+ * copied onto the book only when yaml `cover` is omitted, and then only from
16
+ * the first note. A file name is published at `/media/covers/<file>`.
13
17
  */
14
18
 
15
19
  const WIDGET_FENCE = /```(?:garden|ui-error|ui|intent|widget)(?:[ \t]+[^\n]*)?\r?\n[\s\S]*?```/gu;
@@ -29,6 +33,14 @@ export function resolveBookshelfCover(cover, where = "cover") {
29
33
  return `/media/covers/${parts.map(publishCoverSegment).join("/")}`;
30
34
  }
31
35
 
36
+ /** Book hub for a multi-note volume. One path segment, the book id. */
37
+ export function bookshelfHubPath(id) {
38
+ if (typeof id !== "string" || !id || /[\\/]/.test(id) || id === "." || id === "..") {
39
+ throw new BookshelfConfigError("book", `本书页的 id「${id ?? ""}」必须是单一路径段。`);
40
+ }
41
+ return `/bookshelf/${encodeURIComponent(id)}`;
42
+ }
43
+
32
44
  /** `content/posts` path, without `.md`. Rejects anything that leaves that directory. */
33
45
  export function normalizeBookshelfPostPath(value) {
34
46
  const relative = value
@@ -63,19 +75,64 @@ export function bookshelfMountFromFile(file, posts) {
63
75
  };
64
76
  }
65
77
 
66
- function toCatalogBook(book, byPath, notes) {
67
- const post = book.post ? byPath.get(normalizeBookshelfPostPath(book.post)) : undefined;
68
- if (book.post && !post) {
69
- throw new BookshelfConfigError("post", `书架引用的博文 ${book.post} 没有读到。`);
78
+ function bookPostRefs(book) {
79
+ if (Array.isArray(book.posts) && book.posts.length > 0) {
80
+ return book.posts.map((entry) => (
81
+ typeof entry === "string" ? { path: entry } : { path: entry?.path, kind: entry?.kind }
82
+ ));
70
83
  }
71
- const cover = resolveBookshelfCover(book.cover, book.id) ?? (post ? resolveBookshelfCover(post.cover, book.id) : undefined);
72
- const body = post
73
- ? renderNoteHtml(post.content, post.description)
74
- : book.note
84
+ if (typeof book.post === "string" && book.post.trim()) return [{ path: book.post.trim() }];
85
+ return [];
86
+ }
87
+
88
+ /** Same count the article page prints (`estimateWordCount`). */
89
+ function noteWordCount(markdown) {
90
+ const text = searchableText(markdown ?? "");
91
+ const han = text.match(/[\u3400-\u9fff]/gu)?.length ?? 0;
92
+ const other = text.replace(/[\u3400-\u9fff]/gu, " ").match(/[\p{L}\p{N}]+/gu)?.length ?? 0;
93
+ return han + other;
94
+ }
95
+
96
+ /** Jacket: yaml cover when set. Otherwise the first note's cover, never a later note's. */
97
+ function jacketFor(book, primary) {
98
+ if (book.cover) return resolveBookshelfCover(book.cover, book.id);
99
+ if (primary?.cover) return resolveBookshelfCover(primary.cover, book.id);
100
+ return undefined;
101
+ }
102
+
103
+ function toCatalogBook(book, byPath, notes) {
104
+ const refs = bookPostRefs(book);
105
+ const paths = refs.map((ref) => normalizeBookshelfPostPath(ref.path));
106
+ const sources = paths.map((relative, index) => {
107
+ const post = byPath.get(relative);
108
+ if (!post) throw new BookshelfConfigError("post", `书架引用的博文 ${refs[index].path} 没有读到。`);
109
+ return post;
110
+ });
111
+ const primary = sources[0];
112
+ const multi = sources.length > 1;
113
+ const cover = jacketFor(book, primary);
114
+ // A multi-note book opens on the hub. Its note bodies stay on the article pages.
115
+ const body = !multi && primary
116
+ ? renderNoteHtml(primary.content, primary.description)
117
+ : !multi && book.note
75
118
  ? renderNoteHtml(book.note)
76
119
  : "";
77
120
  const note = body ? book.id : null;
78
- if (note) notes[book.id] = { title: book.title, body, summary: post?.description };
121
+ if (note) notes[book.id] = { title: book.title, body, summary: primary?.description };
122
+ const posts = sources.map((source, index) => {
123
+ const words = noteWordCount(source.content);
124
+ const title = (typeof source.title === "string" && source.title.trim()) || (index === 0 ? book.title : paths[index]);
125
+ const summary = typeof source.description === "string" ? source.description.trim() : "";
126
+ const date = typeof source.date === "string" ? source.date.trim() : "";
127
+ return {
128
+ path: paths[index],
129
+ title,
130
+ ...(refs[index].kind ? { kind: refs[index].kind } : {}),
131
+ ...(summary ? { summary } : {}),
132
+ ...(date ? { date } : {}),
133
+ ...(words > 0 ? { words } : {}),
134
+ };
135
+ });
79
136
  return {
80
137
  id: book.id,
81
138
  title: book.title,
@@ -85,7 +142,10 @@ function toCatalogBook(book, byPath, notes) {
85
142
  cover: cover ?? false,
86
143
  color: book.color,
87
144
  filler: book.filler,
88
- post: book.post ? normalizeBookshelfPostPath(book.post) : undefined,
145
+ post: !multi && paths[0] ? paths[0] : undefined,
146
+ posts: posts.length ? posts : undefined,
147
+ hub: multi ? bookshelfHubPath(book.id) : undefined,
148
+ sources: Array.isArray(book.sources) && book.sources.length ? book.sources : undefined,
89
149
  note,
90
150
  };
91
151
  }
@@ -4,6 +4,10 @@ import type { BookshelfNote, BookshelfShelf } from "./types";
4
4
  * Site shelf ready for `<Bookshelf catalog notes />`.
5
5
  * The runtime lives in `catalog.mjs` (node tests and the page share it).
6
6
  * The package demo is not an input.
7
+ *
8
+ * A book's `cover` is the jacket. A post's `cover` on `BookshelfPostSource` is
9
+ * that note's hero and is used as the jacket only when the yaml book omits
10
+ * `cover`. Multi-note books set `hub` (`/bookshelf/<id>`) and do not fill `notes`.
7
11
  */
8
12
  export type BookshelfMount = {
9
13
  title: string;