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.
- package/app/bookshelf/load.mjs +96 -0
- package/app/bookshelf/load.ts +12 -19
- package/app/bookshelf/page.tsx +43 -10
- package/app/components/SiteChrome.tsx +14 -6
- package/app/components/SiteHeader.tsx +3 -3
- package/app/components/bookshelf/Bookshelf.tsx +64 -9
- package/app/components/bookshelf/bookshelf.css +211 -44
- package/app/components/bookshelf/catalog.mjs +116 -0
- package/app/components/bookshelf/catalog.ts +23 -0
- package/app/components/bookshelf/engine.ts +402 -117
- package/app/components/bookshelf/schema.mjs +86 -0
- package/app/components/bookshelf/schema.ts +25 -87
- package/app/components/bookshelf/types.ts +20 -3
- package/app/garden-modules.d.ts +6 -0
- package/app/layout.tsx +2 -1
- package/app/lib/i18n.ts +2 -2
- package/app/sitemap.ts +3 -0
- package/docs/handbook/config.md +41 -2
- package/docs/handbook/en/config.md +41 -2
- package/package.json +1 -1
- package/scripts/build-content.mjs +17 -1
- package/scripts/handbook-facts.mjs +2 -1
- package/ui/garden/site-nav.mjs +12 -0
- package/vite.config.ts +12 -3
|
@@ -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
|
+
}
|
package/app/bookshelf/load.ts
CHANGED
|
@@ -1,23 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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
|
|
8
|
-
|
|
4
|
+
export type { BookshelfMount };
|
|
5
|
+
|
|
6
|
+
export function bookshelfConfigPath(root?: string) {
|
|
7
|
+
return configPath(root) as string;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
}
|
package/app/bookshelf/page.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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({
|
|
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
|
|
18
|
-
//
|
|
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
|
|
21
|
-
if (path.startsWith("/admin") ||
|
|
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 {
|
|
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
|
|
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("&", "&")
|
|
39
|
+
.replaceAll("<", "<")
|
|
40
|
+
.replaceAll(">", ">")
|
|
41
|
+
.replaceAll('"', """);
|
|
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"
|
|
13
|
-
<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`
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|