hd-garden 0.9.0 → 0.9.1

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.
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { gardenSiteRoot } from "../../scripts/garden-paths.mjs";
6
+ import { bookshelfMountFromFile, normalizeBookshelfPostPath } from "../components/bookshelf/catalog.mjs";
7
+ import { BookshelfConfigError, parseBookshelfConfig } from "../components/bookshelf/schema.mjs";
8
+
9
+ export function bookshelfConfigPath(root = gardenSiteRoot()) {
10
+ return path.join(root, "content", "bookshelf.yaml");
11
+ }
12
+
13
+ /**
14
+ * The site shelf, or null when `content/bookshelf.yaml` is absent.
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` and turned into note HTML.
17
+ * `preparedPosts`, when passed, are the sync pipeline's posts (rewritten asset
18
+ * URLs). Otherwise the markdown files are read directly.
19
+ * Throws `BookshelfConfigError` when the file, a cover, or a post path is broken.
20
+ */
21
+ export function loadBookshelf(root = gardenSiteRoot(), preparedPosts) {
22
+ const file = bookshelfConfigPath(root);
23
+ if (!fs.existsSync(file)) return null;
24
+ let parsed;
25
+ try {
26
+ parsed = parseYaml(fs.readFileSync(file, "utf8"));
27
+ } catch (error) {
28
+ const detail = error instanceof Error ? error.message : "无法解析";
29
+ throw new BookshelfConfigError("invalid-root", `content/bookshelf.yaml 无法解析:${detail}`);
30
+ }
31
+ const config = parseBookshelfConfig(parsed);
32
+ const sources = preparedPosts
33
+ ? preparedPosts.map((post) => ({
34
+ path: post.path,
35
+ title: post.title,
36
+ description: post.description,
37
+ cover: post.cover,
38
+ content: post.content,
39
+ }))
40
+ : readReferencedPosts(root, config);
41
+ return bookshelfMountFromFile(config, sources);
42
+ }
43
+
44
+ function readReferencedPosts(root, config) {
45
+ const seen = new Set();
46
+ const posts = [];
47
+ for (const shelf of config.shelves) {
48
+ for (const book of shelf.books) {
49
+ if (!book.post) continue;
50
+ const relative = normalizeBookshelfPostPath(book.post);
51
+ if (seen.has(relative)) continue;
52
+ seen.add(relative);
53
+ posts.push(readPost(root, relative));
54
+ }
55
+ }
56
+ return posts;
57
+ }
58
+
59
+ function readPost(root, postPath) {
60
+ const relative = normalizeBookshelfPostPath(postPath);
61
+ const postsRoot = path.resolve(root, "content", "posts");
62
+ const file = `${path.resolve(postsRoot, ...relative.split("/"))}.md`;
63
+ if (!file.startsWith(`${postsRoot}${path.sep}`)) {
64
+ throw new BookshelfConfigError("post", `post「${postPath}」必须是 content/posts 下的路径,不带 .md。`);
65
+ }
66
+ if (!fs.existsSync(file)) {
67
+ throw new BookshelfConfigError("post", `content/bookshelf.yaml 引用的博文 ${relative} 不存在。`);
68
+ }
69
+ let parsed;
70
+ try {
71
+ parsed = matter(fs.readFileSync(file, "utf8"), {
72
+ engines: {
73
+ yaml: {
74
+ parse: (input) => parseYaml(input) ?? {},
75
+ },
76
+ },
77
+ });
78
+ } catch (error) {
79
+ const detail = error instanceof Error ? error.message : "无法解析";
80
+ throw new BookshelfConfigError("post", `博文 ${relative} 无法解析:${detail}`);
81
+ }
82
+ const data = parsed.data && typeof parsed.data === "object" ? parsed.data : {};
83
+ return {
84
+ path: relative,
85
+ title: optionalString(data.title),
86
+ description: optionalString(data.description),
87
+ cover: optionalString(data.cover),
88
+ content: parsed.content,
89
+ };
90
+ }
91
+
92
+ function optionalString(value) {
93
+ if (typeof value !== "string") return undefined;
94
+ const trimmed = value.trim();
95
+ return trimmed ? trimmed : undefined;
96
+ }
@@ -1,23 +1,16 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { parse as parseYaml } from "yaml";
4
- import { gardenSiteRoot } from "../../scripts/garden-paths.mjs";
5
- import { BookshelfConfigError, parseBookshelfConfig, type BookshelfFile } from "../components/bookshelf/schema";
1
+ import type { BookshelfMount } from "../components/bookshelf/catalog";
2
+ import { bookshelfConfigPath as configPath, loadBookshelf as readShelf } from "./load.mjs";
6
3
 
7
- export function bookshelfConfigPath(root = gardenSiteRoot()) {
8
- return path.join(root, "content", "bookshelf.yaml");
4
+ export type { BookshelfMount };
5
+
6
+ export function bookshelfConfigPath(root?: string) {
7
+ return configPath(root) as string;
9
8
  }
10
9
 
11
- /** Site shelf, or null when the site has no `content/bookshelf.yaml`. */
12
- export function loadBookshelf(): BookshelfFile | null {
13
- const file = bookshelfConfigPath();
14
- if (!fs.existsSync(file)) return null;
15
- let parsed: unknown;
16
- try {
17
- parsed = parseYaml(fs.readFileSync(file, "utf8"));
18
- } catch (error) {
19
- const detail = error instanceof Error ? error.message : "无法解析";
20
- throw new BookshelfConfigError("invalid-root", `content/bookshelf.yaml 无法解析:${detail}`);
21
- }
22
- return parseBookshelfConfig(parsed);
10
+ /**
11
+ * The site shelf, or null when `content/bookshelf.yaml` is absent.
12
+ * Absent means the package demo. See `load.mjs` for the file contract.
13
+ */
14
+ export function loadBookshelf(root?: string): BookshelfMount | null {
15
+ return readShelf(root) as BookshelfMount | null;
23
16
  }
@@ -1,19 +1,52 @@
1
1
  import type { Metadata } from "next";
2
+ import { bookshelf } from "@garden/bookshelf";
2
3
  import { Bookshelf } from "../components/bookshelf";
3
4
  import { siteLang } from "../lib/i18n";
4
-
5
- const preview = siteLang === "zh";
6
-
7
- export const metadata: Metadata = preview
8
- ? { title: { absolute: "花园 · 读书笔记" }, robots: { index: false, follow: false } }
9
- : { title: "Bookshelf", robots: { index: false, follow: false } };
5
+ import { pageAlternates } from "../lib/page-alternates";
6
+ import { siteConfig } from "../site.config";
10
7
 
11
8
  /**
12
- * Click-test route for the approved shelf.
13
- * States: ?mode=covers (A), default spines (B), hover one spine (C). ?debug=1 shows the demo's mode buttons.
14
- * English sites skip the Chinese demo markup so a locale=en build stays free of CJK.
9
+ * `/bookshelf` contract:
10
+ * - `content/bookshelf.yaml` present → that shelf, with each `post` rendered
11
+ * into `notes`, inside the site header. Indexed like the other pages.
12
+ * - file absent → package demo (Chinese click-test only, noindex, no site chrome).
13
+ * English builds skip the demo so a locale=en site stays free of that markup.
15
14
  */
15
+ export async function generateMetadata(): Promise<Metadata> {
16
+ const shelf = bookshelf;
17
+ if (shelf) {
18
+ return {
19
+ title: shelf.title,
20
+ description: shelf.description,
21
+ alternates: pageAlternates("/bookshelf"),
22
+ };
23
+ }
24
+ if (siteLang === "zh") {
25
+ return { title: { absolute: "花园 · 读书笔记" }, robots: { index: false, follow: false } };
26
+ }
27
+ return { title: "Bookshelf", robots: { index: false, follow: false } };
28
+ }
29
+
16
30
  export default function BookshelfPage() {
17
- if (!preview) return null;
31
+ const shelf = bookshelf;
32
+ if (shelf) {
33
+ return (
34
+ <main className="sh-main bookshelf-page" id="content">
35
+ <header className="sh-head">
36
+ {shelf.eyebrow ? <p className="sh-kicker">{shelf.eyebrow}</p> : null}
37
+ <h1 id="sh-title">{shelf.title}</h1>
38
+ {shelf.description ? <p className="sh-deck">{shelf.description}</p> : null}
39
+ </header>
40
+ <Bookshelf
41
+ embedded
42
+ catalog={shelf.catalog}
43
+ notes={shelf.notes}
44
+ title={shelf.title}
45
+ eyebrow={shelf.eyebrow ?? siteConfig.name}
46
+ />
47
+ </main>
48
+ );
49
+ }
50
+ if (siteLang !== "zh") return null;
18
51
  return <Bookshelf />;
19
52
  }
@@ -5,7 +5,14 @@ import { inAppPath, isHandbookPath, routeLang, ui } from "../lib/i18n";
5
5
  import { SiteFooter } from "./SiteFooter";
6
6
  import { SiteHeader } from "./SiteHeader";
7
7
 
8
- export function SiteChrome({ children }: { children: React.ReactNode }) {
8
+ export function SiteChrome({
9
+ children,
10
+ bookshelfSite = false,
11
+ }: {
12
+ children: React.ReactNode;
13
+ /** True when the site has content/bookshelf.yaml. The package demo stays chrome-less. */
14
+ bookshelfSite?: boolean;
15
+ }) {
9
16
  const pathname = usePathname();
10
17
  const lang = routeLang(pathname);
11
18
  // The skip link follows the route language too (/garden/en/ is English).
@@ -14,18 +21,19 @@ export function SiteChrome({ children }: { children: React.ReactNode }) {
14
21
  {ui(lang).skipToContent}
15
22
  </a>
16
23
  );
17
- // The shelf preview is the approved demo viewport, not a page inside the site header.
18
- // Static export serves /bookshelf/ ; an exact "/bookshelf" check leaves the chrome on.
24
+ // The package demo (no content/bookshelf.yaml) is the approved chrome-less viewport.
25
+ // A site shelf is a normal page, so it keeps the header and footer.
26
+ // Static export serves /bookshelf/ ; an exact "/bookshelf" check would miss it.
19
27
  const path = inAppPath(pathname);
20
- const shelfPreview = path === "/bookshelf" || path.startsWith("/bookshelf/");
21
- if (path.startsWith("/admin") || shelfPreview) {
28
+ const shelfDemo = !bookshelfSite && (path === "/bookshelf" || path.startsWith("/bookshelf/"));
29
+ if (path.startsWith("/admin") || shelfDemo) {
22
30
  return <>{skip}{children}</>;
23
31
  }
24
32
 
25
33
  return (
26
34
  <>
27
35
  {skip}
28
- <SiteHeader />
36
+ <SiteHeader shelf={bookshelfSite} />
29
37
  <div id="main-content">{children}</div>
30
38
  <SiteFooter docs={isHandbookPath(pathname)} lang={lang} />
31
39
  </>
@@ -16,7 +16,7 @@ import { brand, searchFull, searchIndexUrl } from "@garden/site-config";
16
16
  import { createSearch } from "../../ui/garden/search-match.mjs";
17
17
  import { cropMatchedText, highlightParts } from "../../ui/garden/search-preview.mjs";
18
18
  import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
19
- import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
19
+ import { siteNav, isCurrentNav } from "../../ui/garden/site-nav.mjs";
20
20
  import { isHandbookPath, routeLang, ui } from "../lib/i18n";
21
21
  import { HandbookLangSwitch } from "./HandbookLangSwitch";
22
22
  import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
@@ -89,7 +89,6 @@ function loadSearchEngine() {
89
89
  return engineLoad;
90
90
  }
91
91
 
92
- const nav = SITE_NAV.map((item) => ({ ...item, key: item.href, href: siteHref(item.href) }));
93
92
 
94
93
  function rangesFor(
95
94
  matches: readonly SearchMatch[] | undefined,
@@ -140,7 +139,8 @@ function searchPreview(
140
139
  return { prefix: "", text: item.description, suffix: "", ranges: [] };
141
140
  }
142
141
 
143
- export function SiteHeader() {
142
+ export function SiteHeader({ shelf = false }: { shelf?: boolean }) {
143
+ const nav = siteNav({ shelf }).map((item) => ({ ...item, key: item.href, href: siteHref(item.href) }));
144
144
  const pathname = usePathname() || "/";
145
145
  const t = ui(routeLang(pathname));
146
146
  const [menuOpen, setMenuOpen] = useState(false);
@@ -1,16 +1,53 @@
1
1
  "use client";
2
2
 
3
3
  import { useLayoutEffect, useRef } from "react";
4
+ import { siteHref } from "../../lib/site-href";
4
5
  import { mountBookshelf } from "./engine";
5
- import type { BookshelfProps } from "./types";
6
+ import type { BookshelfProps, BookshelfShelf } from "./types";
6
7
  import "./bookshelf.css";
7
8
 
8
- const SHELL = `
9
+ const SHELF_SCROLL_KEY = "garden-bookshelf-scroll";
10
+
11
+ function withNoteLinks(catalog: BookshelfShelf[] | undefined) {
12
+ if (!catalog) return catalog;
13
+ return catalog.map((row) => ({
14
+ ...row,
15
+ books: row.books.map((book) =>
16
+ book.post ? { ...book, href: siteHref(`/blog/${book.post}`) } : book,
17
+ ),
18
+ }));
19
+ }
20
+
21
+ function readSavedScroll() {
22
+ try {
23
+ const raw = sessionStorage.getItem(SHELF_SCROLL_KEY);
24
+ if (raw == null) return null;
25
+ sessionStorage.removeItem(SHELF_SCROLL_KEY);
26
+ const y = Number(raw);
27
+ return Number.isFinite(y) ? y : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ const DEMO_BRAND = "花园 · 读书笔记";
34
+ const DEMO_TITLE = "读书笔记";
35
+
36
+ function escapeHtml(value: string) {
37
+ return value
38
+ .replaceAll("&", "&amp;")
39
+ .replaceAll("<", "&lt;")
40
+ .replaceAll(">", "&gt;")
41
+ .replaceAll('"', "&quot;");
42
+ }
43
+
44
+ function shelfShell(brand: string, title: string) {
45
+ return `
9
46
  <div class="shelf-view" id="shelfView">
10
47
  <div class="page">
11
48
  <header class="top">
12
- <div class="brand">花园 · 读书笔记</div>
13
- <h1>读书笔记</h1>
49
+ <div class="brand">${escapeHtml(brand)}</div>
50
+ <h1>${escapeHtml(title)}</h1>
14
51
  <p class="lede is-debug" id="lede" hidden>调试说明:页级疏密;悬停/点按展一本;再点进笔记。</p>
15
52
  <div class="qa-bar is-debug" id="qaBar" role="group" aria-label="演示控制" hidden>
16
53
  <span class="qa-label">页级态</span>
@@ -37,6 +74,7 @@ const SHELL = `
37
74
  <p class="note-cat" id="noteCat"></p>
38
75
  <h1 id="noteTitle"></h1>
39
76
  <p class="note-author" id="noteAuthor"></p>
77
+ <a class="note-read" id="noteRead" hidden>阅读全文</a>
40
78
  </div>
41
79
  </div>
42
80
  <div class="note-body" id="noteBody"></div>
@@ -49,14 +87,31 @@ const SHELL = `
49
87
  </div>
50
88
  <div class="stage" id="stage" aria-hidden="true"></div>
51
89
  `;
90
+ }
52
91
 
53
- /** Approved bookshelf. Pass `catalog` and `notes` to mount another shelf on the same motion. */
54
- export function Bookshelf({ catalog, notes }: BookshelfProps) {
92
+ /** Approved bookshelf. Pass `catalog` and `notes` for a site shelf; omit them for the package demo. */
93
+ export function Bookshelf({ catalog, notes, title, eyebrow, embedded = false }: BookshelfProps) {
55
94
  const ref = useRef<HTMLDivElement>(null);
56
95
  useLayoutEffect(() => {
57
96
  const root = ref.current;
58
97
  if (!root) return;
59
- return mountBookshelf(root, { catalog, notes });
60
- }, [catalog, notes]);
61
- return <div className="bookshelf-demo" ref={ref} dangerouslySetInnerHTML={{ __html: SHELL }} />;
98
+ const saved = embedded ? readSavedScroll() : null;
99
+ const applyScroll = () => {
100
+ if (saved != null) window.scrollTo(0, saved);
101
+ };
102
+ applyScroll();
103
+ const unmount = mountBookshelf(root, { catalog: withNoteLinks(catalog), notes });
104
+ const frame = window.requestAnimationFrame(applyScroll);
105
+ return () => {
106
+ window.cancelAnimationFrame(frame);
107
+ unmount?.();
108
+ };
109
+ }, [catalog, notes, title, eyebrow, embedded]);
110
+ return (
111
+ <div
112
+ className={embedded ? "bookshelf-demo is-site" : "bookshelf-demo"}
113
+ ref={ref}
114
+ dangerouslySetInnerHTML={{ __html: shelfShell(eyebrow || DEMO_BRAND, title || DEMO_TITLE) }}
115
+ />
116
+ );
62
117
  }
@@ -6,7 +6,10 @@
6
6
  .bookshelf-demo .cover.is-extracting:hover,
7
7
  .bookshelf-demo .spine.is-extracting,
8
8
  .bookshelf-demo .spine.is-extracting:hover,
9
- .bookshelf-demo .spine.is-extracting.is-expanded {
9
+ .bookshelf-demo .spine.is-extracting.is-expanded,
10
+ .bookshelf-demo .spine.is-seat,
11
+ .bookshelf-demo .spine.is-seat:hover,
12
+ .bookshelf-demo .spine.is-seat:focus-visible {
10
13
  transform: none !important;
11
14
  box-shadow: none !important;
12
15
  transition: none !important;
@@ -674,7 +677,7 @@
674
677
  contain: layout style;
675
678
  }
676
679
  .bookshelf-demo .flyer.is-ready {
677
- will-change: transform, opacity;
680
+ will-change: translate, scale, opacity;
678
681
  }
679
682
 
680
683
  /* Soft oval foot shadow — no filter:blur (blur+anim = jitter) */
@@ -1165,3 +1168,121 @@
1165
1168
 
1166
1169
  /* Reading path: hide demo/QA chrome unless ?debug=1 */
1167
1170
  body:not(.debug-on) .bookshelf-demo .is-debug { display: none !important; }
1171
+
1172
+ /* Site shelf: same column, type, and header as the blog. Motion rules above stay. */
1173
+ .bookshelf-page.sh-main {
1174
+ display: flex;
1175
+ flex-direction: column;
1176
+ align-items: center;
1177
+ }
1178
+ .bookshelf-page .sh-head {
1179
+ width: 100%;
1180
+ max-width: var(--col);
1181
+ margin-inline: auto;
1182
+ }
1183
+ .bookshelf-page .sh-kicker {
1184
+ margin: 0 0 12px;
1185
+ font-size: var(--fs-meta);
1186
+ letter-spacing: 0.08em;
1187
+ color: var(--muted);
1188
+ }
1189
+ .bookshelf-demo.is-site {
1190
+ --site-sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
1191
+ width: 100%;
1192
+ max-width: var(--col);
1193
+ margin-inline: auto;
1194
+ background: transparent;
1195
+ min-height: 0;
1196
+ color: inherit;
1197
+ font-family: var(--site-sans);
1198
+ font-size: inherit;
1199
+ line-height: inherit;
1200
+ }
1201
+ .bookshelf-demo.is-site .page {
1202
+ width: 100%;
1203
+ max-width: none;
1204
+ margin: 0;
1205
+ padding: 0 0 8px;
1206
+ }
1207
+ .bookshelf-demo.is-site .top { display: none; }
1208
+ .bookshelf-page .sh-head h1,
1209
+ .bookshelf-page .sh-deck,
1210
+ .bookshelf-page .sh-kicker,
1211
+ body:has(.bookshelf-demo.is-site) .sh-header,
1212
+ body:has(.bookshelf-demo.is-site) .sh-footer {
1213
+ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
1214
+ }
1215
+ body:has(.bookshelf-demo.is-site) .sh-header .sh-word {
1216
+ font-family: "Songti SC", "STSong", "Source Serif 4", Georgia, serif;
1217
+ }
1218
+ /* Overlays stay under the sticky header so open/close does not cover it. */
1219
+ .bookshelf-demo.is-site .open-backdrop {
1220
+ top: var(--hdr);
1221
+ z-index: 30;
1222
+ background: var(--paper);
1223
+ }
1224
+ .bookshelf-demo.is-site .stage { z-index: 32; }
1225
+ .bookshelf-demo.is-site .note-view {
1226
+ top: var(--hdr);
1227
+ background: var(--paper);
1228
+ color: var(--text);
1229
+ }
1230
+ .bookshelf-demo.is-site .note-view.is-preparing,
1231
+ .bookshelf-demo.is-site .note-view.is-open { z-index: 34; }
1232
+ .bookshelf-demo.is-site .open-veil { z-index: 36; }
1233
+ .bookshelf-demo.is-site .note {
1234
+ width: min(100%, var(--col));
1235
+ margin: 0 auto;
1236
+ padding: 28px 0 80px;
1237
+ }
1238
+ .bookshelf-demo.is-site .back-btn,
1239
+ .bookshelf-demo.is-site .note-hero,
1240
+ .bookshelf-demo.is-site .note-body,
1241
+ .bookshelf-demo.is-site .note-read {
1242
+ max-width: none;
1243
+ font-family: var(--site-sans);
1244
+ }
1245
+ .bookshelf-demo.is-site .note-body {
1246
+ font-size: var(--fs-body);
1247
+ line-height: 1.75;
1248
+ color: var(--text);
1249
+ }
1250
+ .bookshelf-demo.is-site .note-body h2 {
1251
+ font-family: inherit;
1252
+ font-size: var(--fs-h2);
1253
+ font-weight: 700;
1254
+ line-height: 1.3;
1255
+ color: var(--ink);
1256
+ }
1257
+ .bookshelf-demo.is-site .note-meta h1 {
1258
+ font-family: inherit;
1259
+ font-weight: 700;
1260
+ font-size: var(--fs-display);
1261
+ line-height: 1.2;
1262
+ color: var(--ink);
1263
+ }
1264
+ .bookshelf-demo .flyer-page-more {
1265
+ pointer-events: auto;
1266
+ align-self: flex-start;
1267
+ margin-top: 8px;
1268
+ font-family: system-ui, sans-serif;
1269
+ font-size: 9px;
1270
+ font-weight: 600;
1271
+ letter-spacing: 0.04em;
1272
+ color: var(--ink, #2a261e);
1273
+ text-decoration: underline;
1274
+ text-underline-offset: 2px;
1275
+ }
1276
+ .bookshelf-demo .note-read {
1277
+ display: inline-block;
1278
+ margin: 12px 0 0;
1279
+ font-size: 14px;
1280
+ color: var(--ink);
1281
+ text-decoration: underline;
1282
+ text-underline-offset: 3px;
1283
+ }
1284
+ .bookshelf-demo .note-read[hidden] { display: none; }
1285
+ .bookshelf-demo.is-site .note-read { color: var(--blue); }
1286
+ @media (max-width: 820px) {
1287
+ .bookshelf-demo.is-site .note { padding: 16px 20px 64px; }
1288
+ }
@@ -0,0 +1,116 @@
1
+ import { marked } from "marked";
2
+ import { BookshelfConfigError } from "./schema.mjs";
3
+
4
+ /**
5
+ * Site shelf for `<Bookshelf catalog notes />`.
6
+ * Built only from `content/bookshelf.yaml` and the posts it names.
7
+ * The package demo is a separate path: this module never reads it.
8
+ *
9
+ * `post` is a `content/posts` path without `.md`. Its front matter description
10
+ * leads the note HTML so the open book's right page is that summary.
11
+ * A cover file name is published at `/media/covers/<file>` (same stem cleanup
12
+ * as content sync). A full URL or a path starting with `/` is kept.
13
+ */
14
+
15
+ const WIDGET_FENCE = /```(?:garden|ui-error|ui|intent|widget)(?:[ \t]+[^\n]*)?\r?\n[\s\S]*?```/gu;
16
+
17
+ export function resolveBookshelfCover(cover, where = "cover") {
18
+ if (!cover?.trim()) return undefined;
19
+ const value = cover.trim();
20
+ if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//iu.test(value) || value.startsWith("/") || value.startsWith("data:")) {
21
+ return value;
22
+ }
23
+ let relative = value.replace(/^\.\//u, "");
24
+ if (relative.startsWith("covers/")) relative = relative.slice("covers/".length);
25
+ const parts = relative.split("/");
26
+ if (!relative || parts.some((segment) => segment === "" || segment === "." || segment === "..")) {
27
+ throw new BookshelfConfigError("cover", `${where} 的 cover 必须是文件名或完整 URL。`);
28
+ }
29
+ return `/media/covers/${parts.map(publishCoverSegment).join("/")}`;
30
+ }
31
+
32
+ /** `content/posts` path, without `.md`. Rejects anything that leaves that directory. */
33
+ export function normalizeBookshelfPostPath(value) {
34
+ const relative = value
35
+ .trim()
36
+ .replaceAll("\\", "/")
37
+ .replace(/^(?:content\/)?posts\//u, "")
38
+ .replace(/\.md$/u, "");
39
+ if (
40
+ !relative
41
+ || relative.startsWith("/")
42
+ || relative.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
43
+ ) {
44
+ throw new BookshelfConfigError("post", `post「${value}」必须是 content/posts 下的路径,不带 .md。`);
45
+ }
46
+ return relative;
47
+ }
48
+
49
+ export function bookshelfMountFromFile(file, posts) {
50
+ const byPath = new Map(posts.map((post) => [normalizeBookshelfPostPath(post.path), post]));
51
+ const notes = {};
52
+ const catalog = file.shelves.map((shelf) => ({
53
+ id: shelf.id,
54
+ name: shelf.name,
55
+ books: shelf.books.map((book) => toCatalogBook(book, byPath, notes)),
56
+ }));
57
+ return {
58
+ title: file.title,
59
+ description: file.description,
60
+ eyebrow: file.eyebrow,
61
+ catalog,
62
+ notes,
63
+ };
64
+ }
65
+
66
+ function toCatalogBook(book, byPath, notes) {
67
+ const post = book.post ? byPath.get(normalizeBookshelfPostPath(book.post)) : undefined;
68
+ if (book.post && !post) {
69
+ throw new BookshelfConfigError("post", `书架引用的博文 ${book.post} 没有读到。`);
70
+ }
71
+ const cover = resolveBookshelfCover(book.cover, book.id) ?? (post ? resolveBookshelfCover(post.cover, book.id) : undefined);
72
+ const body = post
73
+ ? renderNoteHtml(post.content, post.description)
74
+ : book.note
75
+ ? renderNoteHtml(book.note)
76
+ : "";
77
+ const note = body ? book.id : null;
78
+ if (note) notes[book.id] = { title: book.title, body, summary: post?.description };
79
+ return {
80
+ id: book.id,
81
+ title: book.title,
82
+ author: book.author ?? "",
83
+ spine: book.spine ?? book.title,
84
+ size: book.size ?? (book.filler ? "thin" : "s-mid"),
85
+ cover: cover ?? false,
86
+ color: book.color,
87
+ filler: book.filler,
88
+ post: book.post ? normalizeBookshelfPostPath(book.post) : undefined,
89
+ note,
90
+ };
91
+ }
92
+
93
+ function renderNoteHtml(markdown, description) {
94
+ const prose = markdown.replace(WIDGET_FENCE, "\n").trim();
95
+ const parsed = prose ? marked.parse(prose, { async: false }).trim() : "";
96
+ const summary = description?.trim();
97
+ if (!summary || prose.includes(summary)) return parsed;
98
+ return `<p>${escapeHtml(summary)}</p>${parsed ? `\n${parsed}` : ""}`;
99
+ }
100
+
101
+ function publishCoverSegment(segment, index, all) {
102
+ const isFile = index === all.length - 1;
103
+ if (!isFile) return encodeURIComponent(segment.replace(/[^\w\u3400-\u9fff.-]+/gu, "-"));
104
+ const dot = segment.lastIndexOf(".");
105
+ const ext = dot >= 0 ? segment.slice(dot).toLowerCase() : "";
106
+ const stem = dot >= 0 ? segment.slice(0, dot) : segment;
107
+ return encodeURIComponent(`${stem.replace(/[^\w\u3400-\u9fff.-]+/gu, "-")}${ext}`);
108
+ }
109
+
110
+ function escapeHtml(value) {
111
+ return value
112
+ .replaceAll("&", "&amp;")
113
+ .replaceAll("<", "&lt;")
114
+ .replaceAll(">", "&gt;")
115
+ .replaceAll('"', "&quot;");
116
+ }
@@ -0,0 +1,23 @@
1
+ import type { BookshelfNote, BookshelfShelf } from "./types";
2
+
3
+ /**
4
+ * Site shelf ready for `<Bookshelf catalog notes />`.
5
+ * The runtime lives in `catalog.mjs` (node tests and the page share it).
6
+ * The package demo is not an input.
7
+ */
8
+ export type BookshelfMount = {
9
+ title: string;
10
+ description?: string;
11
+ eyebrow?: string;
12
+ catalog: BookshelfShelf[];
13
+ notes: Record<string, BookshelfNote>;
14
+ };
15
+
16
+ /** A post the shelf can turn into a note. `path` is relative to `content/posts`, without `.md`. */
17
+ export type BookshelfPostSource = {
18
+ path: string;
19
+ title?: string;
20
+ description?: string;
21
+ cover?: string;
22
+ content: string;
23
+ };