hd-garden 0.9.2 → 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.
- package/app/bookshelf/[bookId]/page.tsx +55 -0
- package/app/bookshelf/load.mjs +24 -6
- package/app/components/bookshelf/BookHub.tsx +142 -0
- package/app/components/bookshelf/Bookshelf.tsx +14 -3
- package/app/components/bookshelf/catalog.mjs +74 -14
- package/app/components/bookshelf/catalog.ts +4 -0
- package/app/components/bookshelf/engine.ts +15 -2
- 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 +37 -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
|
@@ -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
|
+
}
|
package/app/bookshelf/load.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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;
|
|
@@ -677,18 +677,31 @@ export function mountBookshelf(root, options = {}) {
|
|
|
677
677
|
collapseToken += 1;
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
+
// Multi-note books leave the shelf for /bookshelf/<id>. No extract flyer.
|
|
681
|
+
function leaveForHub(book) {
|
|
682
|
+
const posts = book && Array.isArray(book.posts) ? book.posts : [];
|
|
683
|
+
const href = book && typeof book.hub === "string" ? book.hub : "";
|
|
684
|
+
if (posts.length < 2 || !href) return false;
|
|
685
|
+
const y = scrollLock ? scrollLock.y : readScroll().y;
|
|
686
|
+
try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
|
|
687
|
+
window.location.assign(href);
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
680
691
|
async function onCoverClick(id, el) {
|
|
681
692
|
if (busy) return;
|
|
682
693
|
const found = bookById(id);
|
|
683
694
|
if (!found || found.book.filler) return;
|
|
695
|
+
if (leaveForHub(found.book)) return;
|
|
684
696
|
await openBookFlow(found.book, found.cat, el, { fromExpanded: false, asSpine: false });
|
|
685
697
|
}
|
|
686
698
|
|
|
687
699
|
async function onSpineClick(id, el, ev) {
|
|
688
700
|
if (busy) return;
|
|
689
701
|
const found = bookById(id);
|
|
690
|
-
if (!found) return;
|
|
691
|
-
if (found.book
|
|
702
|
+
if (!found || found.book.filler) return;
|
|
703
|
+
if (leaveForHub(found.book)) return;
|
|
704
|
+
if (!found.book.cover) return;
|
|
692
705
|
|
|
693
706
|
const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
|
|
694
707
|
|
|
@@ -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:
|
|
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
|
-
*
|
|
30
|
-
* - `post` is
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* the same
|
|
35
|
-
*
|
|
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 leaves the shelf for `/bookshelf/<id>`
|
|
63
|
+
* (本书). It does not open `posts[0]` and it does not play the flyer.
|
|
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. The flyer runs only when
|
|
71
|
+
* a single-note book is extracted from the shelf and put back.
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
};
|
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,
|
package/app/styles/shell.css
CHANGED
|
@@ -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}
|
package/docs/handbook/config.md
CHANGED
|
@@ -130,7 +130,9 @@ columns:
|
|
|
130
130
|
|
|
131
131
|
`/bookshelf` 在站点写了 `content/bookshelf.yaml` 时渲染这里的书,并带上站点顶栏,和其他页面一样可以被索引。没有这个文件时,`/bookshelf` 只在引擎包内的中文验收里显示内置 demo(无顶栏、不收录);正式站要真书目,就放下这份文件。
|
|
132
132
|
|
|
133
|
-
|
|
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` | 否 |
|
|
163
|
-
| `books[].color` | 否 |
|
|
178
|
+
| `books[].cover` | 否 | 书衣。文件名或完整 URL。写了就只用它 |
|
|
179
|
+
| `books[].color` | 否 | 书脊和单篇打开后的纸色 |
|
|
164
180
|
| `books[].filler` | 否 | `true`、`sand` 或 `cream`,占位薄册,不能打开 |
|
|
165
|
-
| `books[].post` | 否 |
|
|
166
|
-
| `books[].
|
|
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
|
-
`
|
|
133
|
+
Each `books` entry is one volume. `post: note/ddia` is the one-note shorthand. Several notes use `posts`. Clicking that spine opens `/bookshelf/<id>` and does not open the first note or play the extract 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. 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
|
|
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 |
|
|
166
|
-
| `books[].
|
|
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
|
@@ -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)
|
|
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"),
|