hd-garden 0.9.0 → 0.9.2

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
  }