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.
@@ -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;