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.
- 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/bookshelf.css +88 -42
- package/app/components/bookshelf/catalog.mjs +74 -14
- package/app/components/bookshelf/catalog.ts +4 -0
- package/app/components/bookshelf/engine.ts +167 -84
- 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
|
|
|
@@ -235,18 +235,22 @@
|
|
|
235
235
|
z-index: 2;
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
|
-
.bookshelf-demo .cover.is-placeholder
|
|
239
|
-
|
|
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:
|
|
564
|
-
|
|
565
|
-
|
|
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-
|
|
568
|
-
background-
|
|
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
|
-
/*
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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:
|
|
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
|
-
|
|
795
|
-
|
|
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
|
|
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:
|
|
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
|
|
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;
|