hd-garden 0.6.0 → 0.7.0

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.
Files changed (46) hide show
  1. package/app/admin/preview/[...path]/page.tsx +1 -1
  2. package/app/blog/[...path]/page.tsx +2 -2
  3. package/app/columns/[column]/[...path]/page.tsx +2 -2
  4. package/app/components/ArticleMeasure.tsx +39 -4
  5. package/app/components/GardenDocsNav.tsx +184 -0
  6. package/app/components/MarkdownArticle.tsx +7 -6
  7. package/app/components/SiteChrome.tsx +1 -1
  8. package/app/components/SiteFooter.tsx +10 -3
  9. package/app/garden/[...slug]/page.tsx +148 -0
  10. package/app/garden/page.tsx +61 -128
  11. package/app/garden-modules.d.ts +15 -0
  12. package/app/globals.css +10 -10
  13. package/app/lib/content.ts +16 -10
  14. package/app/lib/garden-docs-meta.ts +83 -0
  15. package/app/lib/heading-number.ts +13 -0
  16. package/app/styles/a2.css +531 -0
  17. package/docs/handbook/assets/reading-layout.svg +26 -0
  18. package/docs/handbook/components/card.md +44 -0
  19. package/docs/handbook/components/chart.md +52 -0
  20. package/docs/handbook/components/compare.md +40 -0
  21. package/docs/handbook/components/figure.md +70 -0
  22. package/docs/handbook/components/file/checklist.md +17 -0
  23. package/docs/handbook/components/file.md +32 -0
  24. package/docs/handbook/components/html.md +67 -0
  25. package/docs/handbook/components/interactive.md +65 -0
  26. package/docs/handbook/components/stat.md +55 -0
  27. package/docs/handbook/components/timeline.md +50 -0
  28. package/docs/handbook/components.md +30 -0
  29. package/docs/handbook/config.md +125 -0
  30. package/docs/handbook/deploy.md +273 -0
  31. package/docs/handbook/design.md +81 -0
  32. package/docs/handbook/index.md +92 -0
  33. package/docs/handbook/start.md +92 -0
  34. package/docs/handbook/workflow.md +81 -0
  35. package/docs/handbook/writing.md +148 -0
  36. package/package.json +2 -1
  37. package/scripts/build-content.mjs +124 -15
  38. package/scripts/handbook-allowlist.mjs +68 -0
  39. package/scripts/handbook-facts.mjs +1104 -0
  40. package/scripts/prepare-pages-output.mjs +32 -9
  41. package/site.config.yaml +2 -2
  42. package/templates/garden-blocks.md +1 -1
  43. package/templates/site/package.json +1 -3
  44. package/ui/garden/catalog.mjs +1 -1
  45. package/ui/preview/dist/garden-ui.esm.js +1 -1
  46. package/ui/preview/dist/garden-ui.iife.js +1 -1
@@ -88,7 +88,7 @@ export default async function DraftPreviewPage({ params }: { params: Promise<{ p
88
88
  <h1 className="ui-admin-preview-title">{typeof data.title === "string" ? data.title : post}</h1>
89
89
  {typeof data.description === "string" ? <p className="ui-studio-meta">{data.description}</p> : null}
90
90
  <article className="article-body">
91
- <MarkdownArticle content={proxyAssets(content, post)} />
91
+ <MarkdownArticle content={proxyAssets(content, post)} numbering={data.numbering === false ? false : undefined} />
92
92
  </article>
93
93
  </section>
94
94
  </AdminUiShell>
@@ -77,7 +77,7 @@ export default async function PostPage({
77
77
  if (isStaticPlaceholder(path)) return null;
78
78
  notFound();
79
79
  }
80
- const headings = extractHeadings(post.content);
80
+ const headings = extractHeadings(post.content, { numbering: post.numbering });
81
81
  const baseUrl = await getSiteUrl();
82
82
  const pathname = postHref(post);
83
83
  const canonicalUrl = absoluteUrl(pathname, baseUrl);
@@ -125,7 +125,7 @@ export default async function PostPage({
125
125
  <ArticleMeasure />
126
126
  <div className="e-content">
127
127
  {post.description ? <p className="lead quote-plain p-summary">{post.description}</p> : null}
128
- <MarkdownArticle content={post.content} />
128
+ <MarkdownArticle content={post.content} numbering={post.numbering} />
129
129
  </div>
130
130
  <ArticleReferences references={post.references} />
131
131
  <Webmentions
@@ -89,7 +89,7 @@ export default async function ColumnEntryPage({
89
89
  const listed = [...series].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
90
90
  const currentIndex = series.findIndex((item) => item.path === entry.path);
91
91
  const next = series[currentIndex + 1];
92
- const headings = extractHeadings(entry.content);
92
+ const headings = extractHeadings(entry.content, { numbering: entry.numbering });
93
93
  const baseUrl = await getSiteUrl();
94
94
  const pathname = columnHref(entry);
95
95
  const canonicalUrl = absoluteUrl(pathname, baseUrl);
@@ -147,7 +147,7 @@ export default async function ColumnEntryPage({
147
147
  <ArticleMeasure />
148
148
  <div className="e-content">
149
149
  {entry.description ? <p className="lead p-summary">{entry.description}</p> : null}
150
- <MarkdownArticle content={entry.content} />
150
+ <MarkdownArticle content={entry.content} numbering={entry.numbering} />
151
151
  </div>
152
152
  <ArticleReferences references={entry.references} />
153
153
  {next && (
@@ -45,7 +45,14 @@ export function ArticleMeasure() {
45
45
  return width;
46
46
  };
47
47
 
48
+ const docPage = article.closest<HTMLElement>(".garden-doc-page");
49
+
48
50
  const placeToc = () => {
51
+ if (docPage) {
52
+ root.removeAttribute("data-toc-yield");
53
+ root.removeAttribute("data-toc-hide");
54
+ return;
55
+ }
49
56
  if (!toc) {
50
57
  root.removeAttribute("data-toc-yield");
51
58
  return;
@@ -74,10 +81,38 @@ export function ArticleMeasure() {
74
81
  }
75
82
  };
76
83
 
84
+ const clampMargin = (margin: { left: number; right: number }, maxRoom: number | null) => {
85
+ if (maxRoom === null) return margin;
86
+ const side = Math.max(0, Math.min(margin.left, maxRoom));
87
+ return { left: side, right: side };
88
+ };
89
+
77
90
  const measure = (refresh: boolean) => {
78
91
  // Measure the text column, not the whole article grid (which includes the TOC rail).
79
92
  const articleBox = (article.querySelector(".markdown-body") ?? article).getBoundingClientRect();
80
93
  const viewport = root.clientWidth;
94
+ let docMaxRoom: number | null = null;
95
+ if (docPage) {
96
+ const sidebar = docPage.querySelector<HTMLElement>(".gdoc-sidebar");
97
+ let leftLimit = 24;
98
+ if (sidebar && getComputedStyle(sidebar).position === "sticky") {
99
+ const sbBox = sidebar.getBoundingClientRect();
100
+ if (sbBox.width > 0 && sbBox.right <= articleBox.left + 1) {
101
+ leftLimit = sbBox.right + 16;
102
+ }
103
+ }
104
+ let rightLimit = viewport - 24;
105
+ if (toc && getComputedStyle(toc).display !== "none") {
106
+ const tocBox = toc.getBoundingClientRect();
107
+ if (tocBox.width > 0 && tocBox.left >= articleBox.right - 1) {
108
+ rightLimit = tocBox.left - translatedX(toc) - 16;
109
+ }
110
+ }
111
+ docMaxRoom = Math.max(
112
+ 0,
113
+ Math.floor(Math.min(articleBox.left - leftLimit, rightLimit - articleBox.right)),
114
+ );
115
+ }
81
116
  const widen = (element: HTMLElement, margin: { left: number; right: number }) => {
82
117
  if (margin.left <= 0) return;
83
118
  element.style.setProperty("--wl", `${margin.left}px`);
@@ -89,13 +124,13 @@ export function ArticleMeasure() {
89
124
  const width = tableWidth(wrap, refresh);
90
125
  resetWide(wrap);
91
126
  if (!width) return;
92
- widen(wrap, tableBreakout({
127
+ widen(wrap, clampMargin(tableBreakout({
93
128
  viewport,
94
129
  articleLeft: articleBox.left,
95
130
  articleRight: articleBox.right,
96
131
  tableClient: wrap.clientWidth,
97
132
  tableScroll: width,
98
- }));
133
+ }), docMaxRoom));
99
134
  });
100
135
  // Images: as wide as they display (2x screenshots at half their pixels), up to 1040px in total.
101
136
  article.querySelectorAll<HTMLElement>(".fig").forEach((figure) => {
@@ -115,7 +150,7 @@ export function ArticleMeasure() {
115
150
  if (!pixels) return;
116
151
  // width="" written by the build is the pixel size, not an author's choice.
117
152
  const attrWidth = known ? Number(image.dataset.aw) || 0 : Number(image.getAttribute("width")) || 0;
118
- const breakout = figureBreakout({
153
+ const breakout = clampMargin(figureBreakout({
119
154
  viewport,
120
155
  articleLeft: articleBox.left,
121
156
  articleRight: articleBox.right,
@@ -123,7 +158,7 @@ export function ArticleMeasure() {
123
158
  imageClient: image.clientWidth,
124
159
  naturalWidth: pixels,
125
160
  attrWidth,
126
- });
161
+ }), docMaxRoom);
127
162
  widen(figure, breakout);
128
163
  // Only a widened image, or one with a width attribute, gets an inline width.
129
164
  const width = figureImageWidth({ naturalWidth: pixels, attrWidth, widened: breakout.left > 0 });
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
4
+ import Link from "next/link";
5
+ import { BookOpenText, CaretRight, List, X } from "@phosphor-icons/react";
6
+ import {
7
+ COMPONENT_BADGE,
8
+ groupHandbookDocs,
9
+ NAV_SHORT_TITLE,
10
+ type GardenDocNavItem,
11
+ } from "../lib/garden-docs-meta";
12
+ import { siteHref } from "../lib/site-href";
13
+
14
+ export function GardenDocsNav({
15
+ docs,
16
+ currentPath = "",
17
+ }: {
18
+ docs: GardenDocNavItem[];
19
+ currentPath?: string;
20
+ }) {
21
+ const groups = groupHandbookDocs(docs);
22
+ const currentDoc = docs.find((doc) => doc.path === currentPath);
23
+ const currentSection = groups.find((group) =>
24
+ group.items.some((item) => item.path === currentPath),
25
+ );
26
+ const [mobileOpen, setMobileOpen] = useState(false);
27
+ const drawerId = useId();
28
+ const triggerRef = useRef<HTMLButtonElement>(null);
29
+ const closeRef = useRef<HTMLButtonElement>(null);
30
+ const restoreFocusRef = useRef(false);
31
+
32
+ const closeMobile = useCallback((restoreFocus = false) => {
33
+ restoreFocusRef.current = restoreFocus;
34
+ setMobileOpen(false);
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ if (mobileOpen || !restoreFocusRef.current) return;
39
+ restoreFocusRef.current = false;
40
+ triggerRef.current?.focus({ preventScroll: true });
41
+ }, [mobileOpen]);
42
+
43
+ useEffect(() => {
44
+ if (!mobileOpen) return;
45
+ const query = window.matchMedia("(max-width: 1080px)");
46
+ if (!query.matches) return;
47
+ const previousOverflow = document.body.style.overflow;
48
+ const focusFrame = window.requestAnimationFrame(() => {
49
+ closeRef.current?.focus({ preventScroll: true });
50
+ });
51
+ const onKeyDown = (event: KeyboardEvent) => {
52
+ if (event.key === "Escape") closeMobile(true);
53
+ };
54
+ const onChange = () => {
55
+ if (!query.matches) setMobileOpen(false);
56
+ };
57
+ document.body.style.overflow = "hidden";
58
+ window.addEventListener("keydown", onKeyDown);
59
+ query.addEventListener("change", onChange);
60
+ return () => {
61
+ window.cancelAnimationFrame(focusFrame);
62
+ document.body.style.overflow = previousOverflow;
63
+ window.removeEventListener("keydown", onKeyDown);
64
+ query.removeEventListener("change", onChange);
65
+ };
66
+ }, [closeMobile, mobileOpen]);
67
+
68
+ const isOverview = currentPath === "";
69
+
70
+ return (
71
+ <>
72
+ <div className="gdoc-mobile-bar" aria-label="说明书目录栏">
73
+ <button
74
+ ref={triggerRef}
75
+ type="button"
76
+ className="gdoc-mobile-trigger"
77
+ aria-controls={drawerId}
78
+ aria-expanded={mobileOpen}
79
+ onClick={() => setMobileOpen(true)}
80
+ >
81
+ <List size={16} weight="bold" aria-hidden="true" />
82
+ <span>说明目录</span>
83
+ </button>
84
+ <a
85
+ className="gdoc-nojs-trigger"
86
+ id={`${drawerId}-open`}
87
+ href={`#${drawerId}`}
88
+ aria-label="打开说明目录"
89
+ >
90
+ <List size={16} weight="bold" aria-hidden="true" />
91
+ <span>说明目录</span>
92
+ </a>
93
+ <div className="gdoc-mobile-crumb" aria-hidden="true">
94
+ <span>{currentSection?.title ?? "Garden 使用说明"}</span>
95
+ <CaretRight size={12} />
96
+ <strong>{currentDoc ? (NAV_SHORT_TITLE[currentDoc.path] ?? currentDoc.title) : "总览与快速入口"}</strong>
97
+ </div>
98
+ </div>
99
+
100
+ <button
101
+ type="button"
102
+ className="gdoc-backdrop"
103
+ aria-label="关闭说明目录"
104
+ tabIndex={mobileOpen ? 0 : -1}
105
+ data-open={mobileOpen}
106
+ onClick={() => closeMobile(true)}
107
+ />
108
+
109
+ <aside
110
+ id={drawerId}
111
+ className="gdoc-sidebar"
112
+ aria-label="Garden 使用说明目录"
113
+ data-open={mobileOpen}
114
+ >
115
+ <div className="gdoc-sidebar-head">
116
+ <Link
117
+ href={siteHref("/garden")}
118
+ className="gdoc-overview-link"
119
+ aria-current={isOverview ? "page" : undefined}
120
+ onClick={() => closeMobile(false)}
121
+ >
122
+ <span className="gdoc-overview-mark">
123
+ <BookOpenText size={16} weight="duotone" aria-hidden="true" />
124
+ <span>Garden 使用说明</span>
125
+ </span>
126
+ <small>总览与快速入口</small>
127
+ </Link>
128
+ <button
129
+ ref={closeRef}
130
+ type="button"
131
+ className="gdoc-mobile-close"
132
+ aria-label="关闭说明目录"
133
+ onClick={() => closeMobile(true)}
134
+ >
135
+ <X size={17} aria-hidden="true" />
136
+ </button>
137
+ <a
138
+ className="gdoc-nojs-close"
139
+ href={`#${drawerId}-open`}
140
+ aria-label="关闭说明目录"
141
+ >
142
+ <X size={17} aria-hidden="true" />
143
+ </a>
144
+ </div>
145
+
146
+ <nav className="gdoc-nav" aria-label="章节导航">
147
+ {groups.map((group) => (
148
+ <div key={group.id} className="gdoc-nav-group">
149
+ <div className="gdoc-nav-group-head">
150
+ <h2>{group.title}</h2>
151
+ {group.note ? <span>{group.note}</span> : null}
152
+ </div>
153
+ <ol className="gdoc-nav-list">
154
+ {group.items.map((item) => {
155
+ const active = item.path === currentPath;
156
+ const badge = COMPONENT_BADGE[item.path];
157
+ const label = NAV_SHORT_TITLE[item.path] ?? item.title;
158
+ return (
159
+ <li key={item.path} className={active ? "is-active" : undefined}>
160
+ <Link
161
+ href={siteHref(`/garden/${item.path}`)}
162
+ aria-current={active ? "page" : undefined}
163
+ onClick={() => closeMobile(false)}
164
+ >
165
+ {badge ? (
166
+ <>
167
+ <code className="gdoc-comp-pill">{badge}</code>
168
+ <span className="gdoc-comp-label">{label}</span>
169
+ </>
170
+ ) : (
171
+ <span>{label}</span>
172
+ )}
173
+ </Link>
174
+ </li>
175
+ );
176
+ })}
177
+ </ol>
178
+ </div>
179
+ ))}
180
+ </nav>
181
+ </aside>
182
+ </>
183
+ );
184
+ }
@@ -8,7 +8,7 @@ import {
8
8
  type HTMLAttributes,
9
9
  type ReactNode,
10
10
  } from "react";
11
- import { articleHeadingBase, articleUsesHeadingNumbers, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
11
+ import { articleHeadingBase, autoNumbersHeadings, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
12
12
  import Link from "next/link";
13
13
  import ReactMarkdown from "react-markdown";
14
14
  import rehypeRaw from "rehype-raw";
@@ -28,7 +28,7 @@ import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs
28
28
  import { siteConfig, siteLinks } from "@garden/site-config";
29
29
  import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
30
30
  import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
31
- import { columnHref, columns, postHref, posts, thoughts } from "../lib/content";
31
+ import { columnHref, columns, gardenDocHref, gardenDocs, postHref, posts, thoughts } from "../lib/content";
32
32
  import { LinkCardRefresh } from "./LinkCardRefresh";
33
33
  import { linkHost } from "../../scripts/link-meta-core.mjs";
34
34
  import { cardDate } from "../../ui/garden/card-refresh.mjs";
@@ -46,10 +46,11 @@ const internalRoute = internalLinkResolver({
46
46
  ...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
47
47
  ],
48
48
  routes: [
49
- "/", "/blog", "/columns", "/thoughts", "/tags", "/about",
49
+ "/", "/blog", "/columns", "/thoughts", "/tags", "/about", "/garden",
50
50
  ...posts.map(postHref),
51
51
  ...columns.map(columnHref),
52
52
  ...thoughts.map((thought) => `/thoughts/${thought.slug}`),
53
+ ...gardenDocs.map(gardenDocHref),
53
54
  ],
54
55
  basePath: siteLinks?.basePath ?? "",
55
56
  // Same shape as every other address in the site (the slash follows trailingSlash).
@@ -207,11 +208,11 @@ const headingText = (children: ReactNode) => {
207
208
  return parts.join("");
208
209
  };
209
210
 
210
- export function MarkdownArticle({ content }: { content: string }) {
211
+ export function MarkdownArticle({ content, numbering }: { content: string; numbering?: boolean }) {
211
212
  // Optional layer 2: a Worker that refreshes card text. Without it, cards stay as built.
212
213
  const cardWorker = content.includes("{card}") ? siteConfig.linkPreview?.worker : undefined;
213
214
  const numbers = { h2: 0, h3: 0 };
214
- const numberedArticle = articleUsesHeadingNumbers(content);
215
+ const autoNumber = autoNumbersHeadings(content, numbering);
215
216
  const headingBase = articleHeadingBase(content);
216
217
  const section = (depth: 2 | 3, children: ReactNode) => {
217
218
  const raw = headingText(children).trim();
@@ -222,7 +223,7 @@ export function MarkdownArticle({ content }: { content: string }) {
222
223
  const prefix = raw.slice(0, raw.length - split.text.length);
223
224
  return { no: split.no, children: stripPrefix(children, prefix) };
224
225
  }
225
- if (numberedArticle) return { no: "", children };
226
+ if (!autoNumber) return { no: "", children };
226
227
  if (depth === 2) { numbers.h2 += 1; numbers.h3 = 0; return { no: String(numbers.h2), children }; }
227
228
  numbers.h3 += 1;
228
229
  return { no: numbers.h2 ? `${numbers.h2}.${numbers.h3}` : String(numbers.h3), children };
@@ -12,7 +12,7 @@ export function SiteChrome({ children }: { children: React.ReactNode }) {
12
12
  <>
13
13
  <SiteHeader />
14
14
  <div id="main-content">{children}</div>
15
- <SiteFooter />
15
+ <SiteFooter docs={pathname?.startsWith("/garden") ?? false} />
16
16
  </>
17
17
  );
18
18
  }
@@ -1,13 +1,20 @@
1
1
  import { GithubLogo, Rss } from "@phosphor-icons/react/ssr";
2
2
  import { githubUrl, siteConfig } from "../site.config";
3
3
 
4
- export function SiteFooter() {
4
+ /** On the Garden docs pages the footer carries no site-owner line. */
5
+ export function SiteFooter({ docs = false }: { docs?: boolean }) {
5
6
  return (
6
7
  <footer className="sh-footer">
7
8
  <div className="sh-foot">
8
9
  <p>
9
- © {new Date().getFullYear()} {siteConfig.author.name} · {siteConfig.footer}
10
- <span className="footer-framework"> · 基于 <a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a> 构建</span>
10
+ {docs ? (
11
+ <span className="footer-framework">基于 <a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a> 构建</span>
12
+ ) : (
13
+ <>
14
+ © {new Date().getFullYear()} {siteConfig.author.name} · {siteConfig.footer}
15
+ <span className="footer-framework"> · 基于 <a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a> 构建</span>
16
+ </>
17
+ )}
11
18
  </p>
12
19
  <div className="sh-foot-links">
13
20
  <a href={githubUrl} rel="me">
@@ -0,0 +1,148 @@
1
+ import Link from "next/link";
2
+ import type { Metadata } from "next";
3
+ import { notFound } from "next/navigation";
4
+ import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
5
+ import {
6
+ extractHeadings,
7
+ findGardenDoc,
8
+ gardenDocHref,
9
+ gardenDocs,
10
+ } from "../../lib/content";
11
+ import { FEED_ALTERNATES } from "../../lib/page-alternates";
12
+ import { absoluteUrl, getSiteUrl } from "../../lib/site";
13
+ import { siteHref } from "../../lib/site-href";
14
+ import { ArticleMeasure } from "../../components/ArticleMeasure";
15
+ import { ArticleReferences } from "../../components/ArticleReferences";
16
+ import { ArticleTitle } from "../../components/ArticleTitle";
17
+ import { MarkdownArticle } from "../../components/MarkdownArticle";
18
+ import { TableOfContents } from "../../components/TableOfContents";
19
+ import { GardenDocsNav } from "../../components/GardenDocsNav";
20
+ import {
21
+ COMPONENT_BADGE,
22
+ HANDBOOK_SECTIONS,
23
+ orderedHandbookDocs,
24
+ toHandbookNavItems,
25
+ } from "../../lib/garden-docs-meta";
26
+ import {
27
+ isStaticPlaceholder,
28
+ STATIC_PLACEHOLDER,
29
+ staticParams,
30
+ } from "../../../ui/garden/static-params.mjs";
31
+
32
+ export function generateStaticParams() {
33
+ return staticParams(
34
+ gardenDocs.map((doc) => ({ slug: doc.path.split("/") })),
35
+ { slug: [STATIC_PLACEHOLDER] },
36
+ );
37
+ }
38
+
39
+ export async function generateMetadata({
40
+ params,
41
+ }: {
42
+ params: Promise<{ slug: string[] }>;
43
+ }) {
44
+ const { slug } = await params;
45
+ const doc = findGardenDoc(slug.join("/"));
46
+ if (!doc) return {};
47
+ const baseUrl = await getSiteUrl();
48
+ const pathname = gardenDocHref(doc);
49
+ return {
50
+ title: `${doc.title} · Garden 使用说明`,
51
+ description: doc.description,
52
+ alternates: { canonical: pathname, types: FEED_ALTERNATES },
53
+ openGraph: {
54
+ type: "article",
55
+ url: absoluteUrl(pathname, baseUrl),
56
+ title: `${doc.title} · Garden 使用说明`,
57
+ description: doc.description,
58
+ },
59
+ } satisfies Metadata;
60
+ }
61
+
62
+ export default async function GardenDocPage({
63
+ params,
64
+ }: {
65
+ params: Promise<{ slug: string[] }>;
66
+ }) {
67
+ const { slug } = await params;
68
+ const doc = findGardenDoc(slug.join("/"));
69
+ if (!doc) {
70
+ if (isStaticPlaceholder(slug)) return null;
71
+ notFound();
72
+ }
73
+
74
+ const ordered = orderedHandbookDocs(gardenDocs);
75
+ const navItems = toHandbookNavItems(gardenDocs);
76
+ const index = ordered.findIndex((item) => item.path === doc.path);
77
+ const prev = index > 0 ? ordered[index - 1] : null;
78
+ const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null;
79
+ const headings = extractHeadings(doc.content);
80
+ const sectionMeta = HANDBOOK_SECTIONS.find((sec) => sec.id === doc.section);
81
+ const componentBadge = COMPONENT_BADGE[doc.path];
82
+
83
+ return (
84
+ <main className="column-reading-page garden-doc-page">
85
+ <GardenDocsNav docs={navItems} currentPath={doc.path} />
86
+
87
+ <article className="article-column garden-doc-article h-entry">
88
+ <header className="gdoc-article-head">
89
+ <div className="gdoc-eyebrow">
90
+ <Link href={siteHref("/garden")} className="gdoc-eyebrow-root">
91
+ Garden 使用说明
92
+ </Link>
93
+ <span aria-hidden="true">/</span>
94
+ <span>{sectionMeta?.title ?? doc.sectionTitle ?? "指南"}</span>
95
+ {componentBadge ? (
96
+ <>
97
+ <code className="gdoc-eyebrow-fence">{`garden ${componentBadge}`}</code>
98
+ </>
99
+ ) : null}
100
+ </div>
101
+ <ArticleTitle title={doc.title} />
102
+ {doc.description ? (
103
+ <p className="gdoc-article-deck p-summary">{doc.description}</p>
104
+ ) : null}
105
+ </header>
106
+
107
+ <ArticleMeasure />
108
+
109
+ <div className="e-content">
110
+ <MarkdownArticle content={doc.content} />
111
+ </div>
112
+
113
+ <ArticleReferences references={doc.references} />
114
+
115
+ <nav className="gdoc-pager" aria-label="说明书上下篇导航">
116
+ {prev ? (
117
+ <Link href={gardenDocHref(prev)} className="gdoc-pager-card is-prev">
118
+ <span className="gdoc-pager-dir">
119
+ <ArrowLeft size={14} aria-hidden="true" />
120
+ 上一篇
121
+ </span>
122
+ <strong>{prev.title}</strong>
123
+ </Link>
124
+ ) : (
125
+ <Link href={siteHref("/garden")} className="gdoc-pager-card is-prev">
126
+ <span className="gdoc-pager-dir">
127
+ <ArrowLeft size={14} aria-hidden="true" />
128
+ 返回总览
129
+ </span>
130
+ <strong>Garden 使用说明首页</strong>
131
+ </Link>
132
+ )}
133
+ {next ? (
134
+ <Link href={gardenDocHref(next)} className="gdoc-pager-card is-next">
135
+ <span className="gdoc-pager-dir">
136
+ 下一篇
137
+ <ArrowRight size={14} aria-hidden="true" />
138
+ </span>
139
+ <strong>{next.title}</strong>
140
+ </Link>
141
+ ) : null}
142
+ </nav>
143
+ </article>
144
+
145
+ <TableOfContents headings={headings} label="本页目录" />
146
+ </main>
147
+ );
148
+ }