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.
- 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 +123 -2
- package/app/components/bookshelf/catalog.mjs +116 -0
- package/app/components/bookshelf/catalog.ts +23 -0
- package/app/components/bookshelf/engine.ts +278 -63
- 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
|
}
|
|
@@ -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:
|
|
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("&", "&")
|
|
113
|
+
.replaceAll("<", "<")
|
|
114
|
+
.replaceAll(">", ">")
|
|
115
|
+
.replaceAll('"', """);
|
|
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
|
+
};
|