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
|
@@ -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"),
|