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.
- package/app/bookshelf/[bookId]/page.tsx +72 -0
- package/app/bookshelf/load.mjs +24 -6
- package/app/components/bookshelf/BookHub.tsx +142 -0
- package/app/components/bookshelf/Bookshelf.tsx +30 -6
- package/app/components/bookshelf/bookshelf.css +78 -60
- package/app/components/bookshelf/catalog.mjs +74 -14
- package/app/components/bookshelf/catalog.ts +4 -0
- package/app/components/bookshelf/engine.ts +406 -109
- package/app/components/bookshelf/index.ts +3 -0
- package/app/components/bookshelf/schema.mjs +70 -1
- package/app/components/bookshelf/schema.ts +52 -10
- package/app/components/bookshelf/types.ts +42 -3
- package/app/lib/i18n.ts +10 -0
- package/app/sitemap.ts +8 -0
- package/app/styles/shell.css +37 -0
- package/docs/handbook/config.md +23 -5
- package/docs/handbook/en/config.md +23 -5
- package/package.json +1 -1
- package/scripts/bookshelf-covers.mjs +62 -0
- package/scripts/build-content.mjs +15 -1
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
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:
|
|
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;
|