hd-garden 0.6.1 → 0.8.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 (123) hide show
  1. package/.agents/skills/garden-ui/SKILL.md +26 -19
  2. package/README.en.md +57 -0
  3. package/README.md +31 -46
  4. package/app/admin/preview/[...path]/page.tsx +1 -1
  5. package/app/blog/[...path]/page.tsx +11 -3
  6. package/app/columns/[column]/[...path]/page.tsx +12 -14
  7. package/app/components/ArticleMeasure.tsx +39 -4
  8. package/app/components/ArticlePager.tsx +32 -0
  9. package/app/components/Comments.tsx +2 -1
  10. package/app/components/FeedMore.tsx +70 -0
  11. package/app/components/GardenDocsNav.tsx +189 -0
  12. package/app/components/HandbookLangRedirect.tsx +30 -0
  13. package/app/components/HandbookLangSwitch.tsx +49 -0
  14. package/app/components/HandbookView.tsx +187 -0
  15. package/app/components/MarkdownArticle.tsx +15 -8
  16. package/app/components/MarkdownImage.tsx +6 -4
  17. package/app/components/SiteChrome.tsx +11 -2
  18. package/app/components/SiteFooter.tsx +12 -3
  19. package/app/components/SiteHeader.tsx +27 -23
  20. package/app/components/TableOfContents.tsx +15 -9
  21. package/app/components/ThoughtFeed.tsx +41 -0
  22. package/app/components/UiLang.tsx +18 -0
  23. package/app/components/Webmentions.tsx +3 -2
  24. package/app/components/garden/FilePreview.tsx +13 -11
  25. package/app/components/garden/GardenBlock.tsx +3 -1
  26. package/app/components/garden/GardenFile.tsx +9 -7
  27. package/app/components/garden/blocks.tsx +34 -24
  28. package/app/components/garden/chrome.tsx +20 -13
  29. package/app/garden/[...slug]/page.tsx +28 -0
  30. package/app/garden/en/[...slug]/page.tsx +28 -0
  31. package/app/garden/en/page.tsx +16 -0
  32. package/app/garden/page.tsx +7 -141
  33. package/app/garden-modules.d.ts +18 -0
  34. package/app/globals.css +10 -10
  35. package/app/layout.tsx +2 -4
  36. package/app/lib/content.ts +27 -11
  37. package/app/lib/garden-docs-meta.ts +138 -0
  38. package/app/lib/handbook-path.ts +11 -0
  39. package/app/lib/heading-number.ts +13 -0
  40. package/app/lib/i18n.ts +222 -0
  41. package/app/lib/page-alternates.ts +2 -2
  42. package/app/not-found.tsx +5 -3
  43. package/app/site.config.ts +1 -1
  44. package/app/styles/a2.css +613 -13
  45. package/app/styles/shell.css +6 -0
  46. package/app/thoughts/page/[n]/page.tsx +44 -0
  47. package/app/thoughts/page.tsx +2 -6
  48. package/bin/garden.mjs +4 -0
  49. package/docs/handbook/ai-writing.md +99 -0
  50. package/docs/handbook/assets/reading-layout.svg +26 -0
  51. package/docs/handbook/components/card.md +46 -0
  52. package/docs/handbook/components/chart.md +54 -0
  53. package/docs/handbook/components/compare.md +38 -0
  54. package/docs/handbook/components/figure.md +72 -0
  55. package/docs/handbook/components/file/checklist.md +17 -0
  56. package/docs/handbook/components/file.md +34 -0
  57. package/docs/handbook/components/html.md +69 -0
  58. package/docs/handbook/components/interactive.md +67 -0
  59. package/docs/handbook/components/stat.md +57 -0
  60. package/docs/handbook/components/timeline.md +52 -0
  61. package/docs/handbook/components.md +30 -0
  62. package/docs/handbook/config.md +125 -0
  63. package/docs/handbook/deploy.md +273 -0
  64. package/docs/handbook/design.md +81 -0
  65. package/docs/handbook/en/ai-writing.md +99 -0
  66. package/docs/handbook/en/components/card.md +46 -0
  67. package/docs/handbook/en/components/chart.md +54 -0
  68. package/docs/handbook/en/components/compare.md +38 -0
  69. package/docs/handbook/en/components/figure.md +72 -0
  70. package/docs/handbook/en/components/file/checklist.md +17 -0
  71. package/docs/handbook/en/components/file.md +34 -0
  72. package/docs/handbook/en/components/html.md +69 -0
  73. package/docs/handbook/en/components/interactive.md +67 -0
  74. package/docs/handbook/en/components/stat.md +57 -0
  75. package/docs/handbook/en/components/timeline.md +52 -0
  76. package/docs/handbook/en/components.md +30 -0
  77. package/docs/handbook/en/config.md +125 -0
  78. package/docs/handbook/en/deploy.md +273 -0
  79. package/docs/handbook/en/design.md +81 -0
  80. package/docs/handbook/en/index.md +192 -0
  81. package/docs/handbook/en/start.md +92 -0
  82. package/docs/handbook/en/workflow.md +81 -0
  83. package/docs/handbook/en/writing.md +156 -0
  84. package/docs/handbook/index.md +187 -0
  85. package/docs/handbook/start.md +92 -0
  86. package/docs/handbook/workflow.md +81 -0
  87. package/docs/handbook/writing.md +156 -0
  88. package/package.json +2 -1
  89. package/scripts/build-content.mjs +144 -15
  90. package/scripts/handbook-allowlist.mjs +68 -0
  91. package/scripts/handbook-facts.mjs +1138 -0
  92. package/scripts/html-lang.mjs +36 -0
  93. package/scripts/static-404.mjs +25 -0
  94. package/scripts/ui-cli.mjs +14 -8
  95. package/site.config.yaml +2 -2
  96. package/templates/garden-blocks.md +1 -1
  97. package/templates/site/package.json +1 -3
  98. package/templates/site/site.config.yaml +2 -0
  99. package/ui/core/apply.mjs +22 -4
  100. package/ui/core/cli-locale.mjs +67 -0
  101. package/ui/core/fence.mjs +18 -3
  102. package/ui/core/fix-prompt.mjs +18 -15
  103. package/ui/core/garden-fence.mjs +64 -0
  104. package/ui/core/scan.mjs +19 -3
  105. package/ui/garden/adjacent.mjs +21 -0
  106. package/ui/garden/catalog.mjs +1 -1
  107. package/ui/garden/check.mjs +6 -0
  108. package/ui/garden/html-lang.mjs +16 -0
  109. package/ui/garden/paging.mjs +30 -0
  110. package/ui/preview/dist/garden-ui.css +9 -9
  111. package/ui/preview/dist/garden-ui.esm.js +169 -169
  112. package/ui/preview/dist/garden-ui.iife.js +169 -169
  113. package/ui/react/components/chart.tsx +7 -4
  114. package/ui/react/components/display.tsx +6 -3
  115. package/ui/react/components/inputs.tsx +6 -3
  116. package/ui/react/components/layout.tsx +6 -4
  117. package/ui/react/components/media.tsx +7 -4
  118. package/ui/react/components/sandbox.tsx +3 -1
  119. package/ui/react/index.tsx +11 -7
  120. package/ui/react/lang.tsx +85 -0
  121. package/ui/react/runtime.tsx +3 -1
  122. package/ui/react/ui.css +9 -9
  123. package/worker/index.ts +8 -1
@@ -0,0 +1,189 @@
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
+ HANDBOOK_SECTIONS,
10
+ HANDBOOK_UI,
11
+ NAV_SHORT_TITLE,
12
+ handbookPath,
13
+ sectionTitle,
14
+ type GardenDocNavItem,
15
+ type HandbookLang,
16
+ } from "../lib/garden-docs-meta";
17
+ import { siteHref } from "../lib/site-href";
18
+
19
+ export function GardenDocsNav({
20
+ docs,
21
+ currentPath = "",
22
+ lang = "zh",
23
+ }: {
24
+ docs: GardenDocNavItem[];
25
+ currentPath?: string;
26
+ lang?: HandbookLang;
27
+ }) {
28
+ const t = HANDBOOK_UI[lang];
29
+ const groups = groupHandbookDocs(docs);
30
+ const currentDoc = docs.find((doc) => doc.path === currentPath);
31
+ const currentSection = groups.find((group) =>
32
+ group.items.some((item) => item.path === currentPath),
33
+ );
34
+ const [mobileOpen, setMobileOpen] = useState(false);
35
+ const drawerId = useId();
36
+ const triggerRef = useRef<HTMLButtonElement>(null);
37
+ const closeRef = useRef<HTMLButtonElement>(null);
38
+ const restoreFocusRef = useRef(false);
39
+
40
+ const closeMobile = useCallback((restoreFocus = false) => {
41
+ restoreFocusRef.current = restoreFocus;
42
+ setMobileOpen(false);
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ if (mobileOpen || !restoreFocusRef.current) return;
47
+ restoreFocusRef.current = false;
48
+ triggerRef.current?.focus({ preventScroll: true });
49
+ }, [mobileOpen]);
50
+
51
+ useEffect(() => {
52
+ if (!mobileOpen) return;
53
+ const query = window.matchMedia("(max-width: 1080px)");
54
+ if (!query.matches) return;
55
+ const previousOverflow = document.body.style.overflow;
56
+ const focusFrame = window.requestAnimationFrame(() => {
57
+ closeRef.current?.focus({ preventScroll: true });
58
+ });
59
+ const onKeyDown = (event: KeyboardEvent) => {
60
+ if (event.key === "Escape") closeMobile(true);
61
+ };
62
+ const onChange = () => {
63
+ if (!query.matches) setMobileOpen(false);
64
+ };
65
+ document.body.style.overflow = "hidden";
66
+ window.addEventListener("keydown", onKeyDown);
67
+ query.addEventListener("change", onChange);
68
+ return () => {
69
+ window.cancelAnimationFrame(focusFrame);
70
+ document.body.style.overflow = previousOverflow;
71
+ window.removeEventListener("keydown", onKeyDown);
72
+ query.removeEventListener("change", onChange);
73
+ };
74
+ }, [closeMobile, mobileOpen]);
75
+
76
+ const isOverview = currentPath === "";
77
+
78
+ return (
79
+ <>
80
+ <div className="gdoc-mobile-bar" aria-label={t.mobileBar}>
81
+ <button
82
+ ref={triggerRef}
83
+ type="button"
84
+ className="gdoc-mobile-trigger"
85
+ aria-controls={drawerId}
86
+ aria-expanded={mobileOpen}
87
+ onClick={() => setMobileOpen(true)}
88
+ >
89
+ <List size={16} weight="bold" aria-hidden="true" />
90
+ <span>{t.sidebarShort}</span>
91
+ </button>
92
+ <a
93
+ className="gdoc-nojs-trigger"
94
+ id={`${drawerId}-open`}
95
+ href={`#${drawerId}`}
96
+ aria-label={t.openSidebar}
97
+ >
98
+ <List size={16} weight="bold" aria-hidden="true" />
99
+ <span>{t.sidebarShort}</span>
100
+ </a>
101
+ <div className="gdoc-mobile-crumb" aria-hidden="true">
102
+ <span>{sectionTitle(currentSection, lang) ?? t.name}</span>
103
+ <CaretRight size={12} />
104
+ <strong>{currentDoc ? (lang === "en" ? currentDoc.navTitle ?? currentDoc.title : NAV_SHORT_TITLE[currentDoc.path] ?? currentDoc.title) : t.overviewHint}</strong>
105
+ </div>
106
+ </div>
107
+
108
+ <button
109
+ type="button"
110
+ className="gdoc-backdrop"
111
+ aria-label={t.closeSidebar}
112
+ tabIndex={mobileOpen ? 0 : -1}
113
+ data-open={mobileOpen}
114
+ onClick={() => closeMobile(true)}
115
+ />
116
+
117
+ <aside
118
+ id={drawerId}
119
+ className="gdoc-sidebar"
120
+ aria-label={t.sidebar}
121
+ data-open={mobileOpen}
122
+ >
123
+ <div className="gdoc-sidebar-head">
124
+ <Link
125
+ href={siteHref(handbookPath(lang))}
126
+ className="gdoc-overview-link"
127
+ aria-current={isOverview ? "page" : undefined}
128
+ onClick={() => closeMobile(false)}
129
+ >
130
+ <span className="gdoc-overview-mark">
131
+ <BookOpenText size={16} weight="duotone" aria-hidden="true" />
132
+ <span>{t.name}</span>
133
+ </span>
134
+ <small>{t.overviewHint}</small>
135
+ </Link>
136
+ <button
137
+ ref={closeRef}
138
+ type="button"
139
+ className="gdoc-mobile-close"
140
+ aria-label={t.closeSidebar}
141
+ onClick={() => closeMobile(true)}
142
+ >
143
+ <X size={17} aria-hidden="true" />
144
+ </button>
145
+ <a
146
+ className="gdoc-nojs-close"
147
+ href={`#${drawerId}-open`}
148
+ aria-label={t.closeSidebar}
149
+ >
150
+ <X size={17} aria-hidden="true" />
151
+ </a>
152
+ </div>
153
+
154
+ <nav className="gdoc-nav" aria-label={t.chapters}>
155
+ {groups.map((group) => (
156
+ <div key={group.id} className="gdoc-nav-group">
157
+ <h2 className="gdoc-nav-group-head">{sectionTitle(HANDBOOK_SECTIONS.find((sec) => sec.id === group.id), lang) ?? group.title}</h2>
158
+ <ol className="gdoc-nav-list">
159
+ {group.items.map((item) => {
160
+ const active = item.path === currentPath;
161
+ const badge = COMPONENT_BADGE[item.path];
162
+ const label = lang === "en" ? item.navTitle ?? item.title : NAV_SHORT_TITLE[item.path] ?? item.title;
163
+ return (
164
+ <li key={item.path} className={active ? "is-active" : undefined}>
165
+ <Link
166
+ href={siteHref(handbookPath(lang, item.path))}
167
+ aria-current={active ? "page" : undefined}
168
+ onClick={() => closeMobile(false)}
169
+ >
170
+ {badge ? (
171
+ <>
172
+ <code className="gdoc-comp-pill">{badge}</code>
173
+ <span className="gdoc-comp-label">{label}</span>
174
+ </>
175
+ ) : (
176
+ <span>{label}</span>
177
+ )}
178
+ </Link>
179
+ </li>
180
+ );
181
+ })}
182
+ </ol>
183
+ </div>
184
+ ))}
185
+ </nav>
186
+ </aside>
187
+ </>
188
+ );
189
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { HANDBOOK_LANG_KEY, type HandbookLang } from "../lib/garden-docs-meta";
5
+ import { swapHandbookLang } from "../lib/handbook-path";
6
+
7
+ /**
8
+ * First visit: follow the browser language. Afterwards: follow the reader's
9
+ * saved choice (set by the 中 / EN switch). Only moves to a page that exists,
10
+ * which the hreflang link in <head> tells us.
11
+ */
12
+ export function HandbookLangRedirect({ lang }: { lang: HandbookLang }) {
13
+ useEffect(() => {
14
+ let saved: string | null = null;
15
+ try {
16
+ saved = localStorage.getItem(HANDBOOK_LANG_KEY);
17
+ } catch {}
18
+ const wanted: HandbookLang = saved === "en" || saved === "zh"
19
+ ? saved
20
+ : /^en\b/i.test(navigator.language || "") ? "en" : "zh";
21
+ if (wanted === lang) return;
22
+ const link = document.querySelector<HTMLLinkElement>(`link[rel="alternate"][hreflang="${wanted === "en" ? "en" : "zh-CN"}"]`);
23
+ if (!link?.href) return;
24
+ // The hreflang link says the page exists; the address comes from this one,
25
+ // so the origin and any basePath stay as they are.
26
+ const pathname = swapHandbookLang(location.pathname, wanted);
27
+ if (pathname && pathname !== location.pathname) location.replace(pathname + location.search + location.hash);
28
+ }, [lang]);
29
+ return null;
30
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+ import { usePathname } from "next/navigation";
5
+ import { HANDBOOK_LANG_KEY, handbookBase, type HandbookLang } from "../lib/garden-docs-meta";
6
+ import { siteHref } from "../lib/site-href";
7
+ import { swapHandbookLang } from "../lib/handbook-path";
8
+
9
+ const noSubscribe = () => () => {};
10
+
11
+ /** The counterpart page from the hreflang link in <head>, on this origin. */
12
+ function counterpartHref(target: HandbookLang) {
13
+ const link = document.querySelector<HTMLLinkElement>(`link[rel="alternate"][hreflang="${target === "en" ? "en" : "zh-CN"}"]`);
14
+ if (link?.href) return swapHandbookLang(location.pathname, target);
15
+ return target === "zh" ? siteHref(`${handbookBase("zh")}/`) : null;
16
+ }
17
+
18
+ /** 「中 / EN」 in the top bar on handbook pages. Goes to the counterpart page. */
19
+ export function HandbookLangSwitch() {
20
+ const pathname = usePathname() ?? "";
21
+ const lang: HandbookLang = /\/garden\/en(\/|$)/.test(pathname) ? "en" : "zh";
22
+ const target: HandbookLang = lang === "en" ? "zh" : "en";
23
+ const href = useSyncExternalStore(
24
+ noSubscribe,
25
+ () => counterpartHref(target),
26
+ () => null,
27
+ );
28
+
29
+ const remember = () => {
30
+ try {
31
+ localStorage.setItem(HANDBOOK_LANG_KEY, target);
32
+ } catch {}
33
+ };
34
+ const label = (
35
+ <>
36
+ <span aria-current={lang === "zh" ? "true" : undefined}>中</span>
37
+ <span aria-hidden="true"> / </span>
38
+ <span aria-current={lang === "en" ? "true" : undefined}>EN</span>
39
+ </>
40
+ );
41
+ if (!href) {
42
+ return <span className="sh-lang is-disabled" title="No English version of this page yet">{label}</span>;
43
+ }
44
+ return (
45
+ <a className="sh-lang" href={href} hrefLang={target === "en" ? "en" : "zh-CN"} lang={target === "en" ? "en" : undefined} onClick={remember} aria-label={target === "en" ? "Read in English" : "Switch to Chinese"}>
46
+ {label}
47
+ </a>
48
+ );
49
+ }
@@ -0,0 +1,187 @@
1
+ import Link from "next/link";
2
+ import type { Metadata } from "next";
3
+ import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
4
+ import { ArticleMeasure } from "./ArticleMeasure";
5
+ import { ArticleReferences } from "./ArticleReferences";
6
+ import { ArticleTitle } from "./ArticleTitle";
7
+ import { GardenDocsNav } from "./GardenDocsNav";
8
+ import { HandbookLangRedirect } from "./HandbookLangRedirect";
9
+ import { MarkdownArticle } from "./MarkdownArticle";
10
+ import { TableOfContents } from "./TableOfContents";
11
+ import { extractHeadings, handbookDocs, handbookIndexDoc, hasHandbookPage, type GardenDocEntry } from "../lib/content";
12
+ import { pageAlternates } from "../lib/page-alternates";
13
+ import { absoluteUrl, getSiteUrl } from "../lib/site";
14
+ import { siteHref } from "../lib/site-href";
15
+ import {
16
+ COMPONENT_BADGE,
17
+ HANDBOOK_SECTIONS,
18
+ HANDBOOK_UI,
19
+ handbookPath,
20
+ orderedHandbookDocs,
21
+ sectionTitle,
22
+ toHandbookNavItems,
23
+ type HandbookLang,
24
+ } from "../lib/garden-docs-meta";
25
+ import { siteConfig } from "../site.config";
26
+
27
+ const other = (lang: HandbookLang): HandbookLang => (lang === "en" ? "zh" : "en");
28
+ const hreflang = (lang: HandbookLang) => (lang === "en" ? "en" : "zh-CN");
29
+
30
+ /** canonical + hreflang links; the counterpart is listed only when it exists. */
31
+ export function handbookLanguages(lang: HandbookLang, docPath: string) {
32
+ if (!hasHandbookPage(other(lang), docPath)) return undefined;
33
+ const zh = siteHref(handbookPath("zh", docPath));
34
+ const en = siteHref(handbookPath("en", docPath));
35
+ return { "zh-CN": zh, en, "x-default": zh };
36
+ }
37
+
38
+ export const handbookAlternates = (lang: HandbookLang, docPath: string) =>
39
+ pageAlternates(handbookPath(lang, docPath), handbookLanguages(lang, docPath));
40
+
41
+ export async function handbookDocMetadata(lang: HandbookLang, doc: GardenDocEntry | undefined) {
42
+ if (!doc) return {};
43
+ const t = HANDBOOK_UI[lang];
44
+ const baseUrl = await getSiteUrl();
45
+ const pathname = siteHref(handbookPath(lang, doc.path));
46
+ return {
47
+ title: `${doc.title} · ${t.name}`,
48
+ description: doc.description,
49
+ alternates: handbookAlternates(lang, doc.path),
50
+ openGraph: {
51
+ type: "article",
52
+ url: absoluteUrl(pathname, baseUrl),
53
+ title: `${doc.title} · ${t.name}`,
54
+ description: doc.description,
55
+ locale: lang === "en" ? "en_US" : "zh_CN",
56
+ },
57
+ } satisfies Metadata;
58
+ }
59
+
60
+ export function HandbookOverview({ lang }: { lang: HandbookLang }) {
61
+ const t = HANDBOOK_UI[lang];
62
+ const docs = handbookDocs(lang);
63
+ const indexDoc = handbookIndexDoc(lang);
64
+ const navItems = toHandbookNavItems(docs);
65
+ const firstDoc = orderedHandbookDocs(docs)[0] ?? null;
66
+ const content = indexDoc?.content ?? "";
67
+ const headings = extractHeadings(content);
68
+ // The site config title/subtitle is written in the site's own language.
69
+ const configured = lang === "zh" ? siteConfig.pages.garden : { title: "", subtitle: "" };
70
+ const title = configured.title?.trim() || indexDoc?.title || t.name;
71
+ const subtitle = configured.subtitle?.trim() || indexDoc?.description || "";
72
+
73
+ return (
74
+ <main className="column-reading-page garden-doc-page" lang={hreflang(lang)}>
75
+ <HandbookLangRedirect lang={lang} />
76
+ <GardenDocsNav docs={navItems} currentPath="" lang={lang} />
77
+
78
+ <article className="article-column garden-doc-article h-entry">
79
+ <header className="gdoc-article-head">
80
+ <div className="gdoc-eyebrow">
81
+ <span>{t.name}</span>
82
+ <span aria-hidden="true">/</span>
83
+ <span>{t.overview}</span>
84
+ </div>
85
+ <ArticleTitle title={title} />
86
+ {subtitle ? <p className="gdoc-article-deck p-summary">{subtitle}</p> : null}
87
+ </header>
88
+
89
+ <ArticleMeasure />
90
+
91
+ <div className="e-content">
92
+ <MarkdownArticle content={content} lang={lang} />
93
+ </div>
94
+
95
+ <ArticleReferences references={indexDoc?.references} />
96
+
97
+ {firstDoc ? (
98
+ <nav className="gdoc-pager" aria-label={t.pager}>
99
+ <Link href={siteHref(handbookPath(lang, firstDoc.path))} className="gdoc-pager-card is-next">
100
+ <span className="gdoc-pager-dir">
101
+ {t.next}
102
+ <ArrowRight size={14} aria-hidden="true" />
103
+ </span>
104
+ <strong>{firstDoc.title}</strong>
105
+ </Link>
106
+ </nav>
107
+ ) : null}
108
+ </article>
109
+
110
+ <TableOfContents headings={headings} label={t.toc} lang={lang} />
111
+ </main>
112
+ );
113
+ }
114
+
115
+ export function HandbookDoc({ lang, doc }: { lang: HandbookLang; doc: GardenDocEntry }) {
116
+ const t = HANDBOOK_UI[lang];
117
+ const docs = handbookDocs(lang);
118
+ const ordered = orderedHandbookDocs(docs);
119
+ const navItems = toHandbookNavItems(docs);
120
+ const index = ordered.findIndex((item) => item.path === doc.path);
121
+ const prev = index > 0 ? ordered[index - 1] : null;
122
+ const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null;
123
+ const headings = extractHeadings(doc.content);
124
+ const sectionMeta = HANDBOOK_SECTIONS.find((sec) => sec.id === doc.section);
125
+ const componentBadge = COMPONENT_BADGE[doc.path];
126
+
127
+ return (
128
+ <main className="column-reading-page garden-doc-page" lang={hreflang(lang)}>
129
+ <HandbookLangRedirect lang={lang} />
130
+ <GardenDocsNav docs={navItems} currentPath={doc.path} lang={lang} />
131
+
132
+ <article className="article-column garden-doc-article h-entry">
133
+ <header className="gdoc-article-head">
134
+ <div className="gdoc-eyebrow">
135
+ <Link href={siteHref(handbookPath(lang))} className="gdoc-eyebrow-root">
136
+ {t.name}
137
+ </Link>
138
+ <span aria-hidden="true">/</span>
139
+ <span>{sectionTitle(sectionMeta, lang) ?? doc.sectionTitle ?? t.guide}</span>
140
+ {componentBadge ? <code className="gdoc-eyebrow-fence">{`garden ${componentBadge}`}</code> : null}
141
+ </div>
142
+ <ArticleTitle title={doc.title} />
143
+ {doc.description ? <p className="gdoc-article-deck p-summary">{doc.description}</p> : null}
144
+ </header>
145
+
146
+ <ArticleMeasure />
147
+
148
+ <div className="e-content">
149
+ <MarkdownArticle content={doc.content} lang={lang} />
150
+ </div>
151
+
152
+ <ArticleReferences references={doc.references} />
153
+
154
+ <nav className="gdoc-pager" aria-label={t.pager}>
155
+ {prev ? (
156
+ <Link href={siteHref(handbookPath(lang, prev.path))} className="gdoc-pager-card is-prev">
157
+ <span className="gdoc-pager-dir">
158
+ <ArrowLeft size={14} aria-hidden="true" />
159
+ {t.prev}
160
+ </span>
161
+ <strong>{prev.title}</strong>
162
+ </Link>
163
+ ) : (
164
+ <Link href={siteHref(handbookPath(lang))} className="gdoc-pager-card is-prev">
165
+ <span className="gdoc-pager-dir">
166
+ <ArrowLeft size={14} aria-hidden="true" />
167
+ {t.backToOverview}
168
+ </span>
169
+ <strong>{t.overviewDocTitle}</strong>
170
+ </Link>
171
+ )}
172
+ {next ? (
173
+ <Link href={siteHref(handbookPath(lang, next.path))} className="gdoc-pager-card is-next">
174
+ <span className="gdoc-pager-dir">
175
+ {t.next}
176
+ <ArrowRight size={14} aria-hidden="true" />
177
+ </span>
178
+ <strong>{next.title}</strong>
179
+ </Link>
180
+ ) : null}
181
+ </nav>
182
+ </article>
183
+
184
+ <TableOfContents headings={headings} label={t.toc} lang={lang} />
185
+ </main>
186
+ );
187
+ }
@@ -1,3 +1,5 @@
1
+ import { UiLangProvider } from "./UiLang";
2
+ import { ui, type UiLang } from "../lib/i18n";
1
3
  import { markdownPlugins } from "../lib/markdown-plugins";
2
4
  import { remarkA2 } from "../lib/remark-a2.mjs";
3
5
  import { remarkLinkCard } from "../lib/remark-link-card.mjs";
@@ -8,7 +10,7 @@ import {
8
10
  type HTMLAttributes,
9
11
  type ReactNode,
10
12
  } from "react";
11
- import { articleHeadingBase, articleUsesHeadingNumbers, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
13
+ import { articleHeadingBase, autoNumbersHeadings, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
12
14
  import Link from "next/link";
13
15
  import ReactMarkdown from "react-markdown";
14
16
  import rehypeRaw from "rehype-raw";
@@ -28,7 +30,7 @@ import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs
28
30
  import { siteConfig, siteLinks } from "@garden/site-config";
29
31
  import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
30
32
  import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
31
- import { columnHref, columns, postHref, posts, thoughts } from "../lib/content";
33
+ import { columnHref, columns, gardenDocHref, gardenDocs, gardenDocsEn, postHref, posts, thoughts } from "../lib/content";
32
34
  import { LinkCardRefresh } from "./LinkCardRefresh";
33
35
  import { linkHost } from "../../scripts/link-meta-core.mjs";
34
36
  import { cardDate } from "../../ui/garden/card-refresh.mjs";
@@ -46,10 +48,12 @@ const internalRoute = internalLinkResolver({
46
48
  ...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
47
49
  ],
48
50
  routes: [
49
- "/", "/blog", "/columns", "/thoughts", "/tags", "/about",
51
+ "/", "/blog", "/columns", "/thoughts", "/tags", "/about", "/garden", "/garden/en",
50
52
  ...posts.map(postHref),
51
53
  ...columns.map(columnHref),
52
54
  ...thoughts.map((thought) => `/thoughts/${thought.slug}`),
55
+ ...gardenDocs.map(gardenDocHref),
56
+ ...gardenDocsEn.map((doc) => siteHref(`/garden/en/${doc.path}`)),
53
57
  ],
54
58
  basePath: siteLinks?.basePath ?? "",
55
59
  // Same shape as every other address in the site (the slash follows trailingSlash).
@@ -207,11 +211,12 @@ const headingText = (children: ReactNode) => {
207
211
  return parts.join("");
208
212
  };
209
213
 
210
- export function MarkdownArticle({ content }: { content: string }) {
214
+ export function MarkdownArticle({ content, numbering, lang }: { content: string; numbering?: boolean; lang?: UiLang }) {
215
+ const t = ui(lang);
211
216
  // Optional layer 2: a Worker that refreshes card text. Without it, cards stay as built.
212
217
  const cardWorker = content.includes("{card}") ? siteConfig.linkPreview?.worker : undefined;
213
218
  const numbers = { h2: 0, h3: 0 };
214
- const numberedArticle = articleUsesHeadingNumbers(content);
219
+ const autoNumber = autoNumbersHeadings(content, numbering);
215
220
  const headingBase = articleHeadingBase(content);
216
221
  const section = (depth: 2 | 3, children: ReactNode) => {
217
222
  const raw = headingText(children).trim();
@@ -222,7 +227,7 @@ export function MarkdownArticle({ content }: { content: string }) {
222
227
  const prefix = raw.slice(0, raw.length - split.text.length);
223
228
  return { no: split.no, children: stripPrefix(children, prefix) };
224
229
  }
225
- if (numberedArticle) return { no: "", children };
230
+ if (!autoNumber) return { no: "", children };
226
231
  if (depth === 2) { numbers.h2 += 1; numbers.h3 = 0; return { no: String(numbers.h2), children }; }
227
232
  numbers.h3 += 1;
228
233
  return { no: numbers.h2 ? `${numbers.h2}.${numbers.h3}` : String(numbers.h3), children };
@@ -247,7 +252,7 @@ export function MarkdownArticle({ content }: { content: string }) {
247
252
  "garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => {
248
253
  const payload = props.dataPayload ?? props["data-payload"];
249
254
  const block = readGardenPayload(payload);
250
- if (block?.name === "file" && !block.error) return <GardenFile props={(block.props ?? {}) as Record<string, unknown>} />;
255
+ if (block?.name === "file" && !block.error) return <GardenFile props={(block.props ?? {}) as Record<string, unknown>} lang={lang} />;
251
256
  return <GardenBlock dataPayload={payload} />;
252
257
  },
253
258
  "garden-codediff": (props: {
@@ -265,6 +270,7 @@ export function MarkdownArticle({ content }: { content: string }) {
265
270
  ),
266
271
  };
267
272
  return (
273
+ <UiLangProvider lang={lang}>
268
274
  <div className="markdown-body prose">
269
275
  <ReactMarkdown
270
276
  remarkPlugins={[...markdownPlugins, [remarkLinkCard, { sameSite }], [remarkA2, { imageSize: (src: string) => lookupImageSize(imageSizes, src) }]]}
@@ -319,7 +325,7 @@ export function MarkdownArticle({ content }: { content: string }) {
319
325
  return <MarkdownImage {...props} {...dims} style={{ ...props.style, ...dims.style }} />;
320
326
  },
321
327
  table: ({ children, ...props }) => (
322
- <div className="table-wrap" role="region" aria-label="表格,可横向滚动" tabIndex={0}>
328
+ <div className="table-wrap" role="region" aria-label={t.tableScroll} tabIndex={0}>
323
329
  <table {...props}>{children}</table>
324
330
  </div>
325
331
  ),
@@ -355,5 +361,6 @@ export function MarkdownArticle({ content }: { content: string }) {
355
361
  </ReactMarkdown>
356
362
  {cardWorker ? <LinkCardRefresh endpoint={cardWorker} /> : null}
357
363
  </div>
364
+ </UiLangProvider>
358
365
  );
359
366
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useUi } from "./UiLang";
3
4
  import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
4
5
 
5
6
  // react-markdown also hands over its syntax-tree `node`; it must not reach the DOM.
@@ -8,7 +9,8 @@ type MarkdownImageProps = ComponentPropsWithoutRef<"img"> & { node?: unknown };
8
9
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
9
10
  export function MarkdownImage({ alt = "", src, node, style, ...props }: MarkdownImageProps) {
10
11
  const [open, setOpen] = useState(false);
11
- const description = alt || "查看原图";
12
+ const t = useUi();
13
+ const description = alt || t.viewImage;
12
14
 
13
15
  useEffect(() => {
14
16
  if (!open) return undefined;
@@ -33,7 +35,7 @@ export function MarkdownImage({ alt = "", src, node, style, ...props }: Markdown
33
35
  type="button"
34
36
  className="markdown-image-trigger"
35
37
  onClick={() => setOpen(true)}
36
- aria-label={`放大查看:${description}`}
38
+ aria-label={t.zoomImage(description)}
37
39
  >
38
40
  {/* Native images preserve Typora-authored relative paths. */}
39
41
  {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -56,9 +58,9 @@ export function MarkdownImage({ alt = "", src, node, style, ...props }: Markdown
56
58
  type="button"
57
59
  className="markdown-image-lightbox-close"
58
60
  onClick={() => setOpen(false)}
59
- aria-label="关闭图片预览"
61
+ aria-label={t.closeImage}
60
62
  >
61
- 关闭
63
+ {t.close.trim()}
62
64
  </button>
63
65
  </div>
64
66
  </div>
@@ -1,18 +1,27 @@
1
1
  "use client";
2
2
 
3
3
  import { usePathname } from "next/navigation";
4
+ import { routeLang, ui } from "../lib/i18n";
4
5
  import { SiteFooter } from "./SiteFooter";
5
6
  import { SiteHeader } from "./SiteHeader";
6
7
 
7
8
  export function SiteChrome({ children }: { children: React.ReactNode }) {
8
9
  const pathname = usePathname();
9
- if (pathname?.startsWith("/admin")) return <>{children}</>;
10
+ const lang = routeLang(pathname);
11
+ // The skip link follows the route language too (/garden/en/ is English).
12
+ const skip = (
13
+ <a className="skip-link" href="#main-content">
14
+ {ui(lang).skipToContent}
15
+ </a>
16
+ );
17
+ if (pathname?.startsWith("/admin")) return <>{skip}{children}</>;
10
18
 
11
19
  return (
12
20
  <>
21
+ {skip}
13
22
  <SiteHeader />
14
23
  <div id="main-content">{children}</div>
15
- <SiteFooter />
24
+ <SiteFooter docs={pathname?.startsWith("/garden") ?? false} lang={lang} />
16
25
  </>
17
26
  );
18
27
  }
@@ -1,13 +1,22 @@
1
1
  import { GithubLogo, Rss } from "@phosphor-icons/react/ssr";
2
2
  import { githubUrl, siteConfig } from "../site.config";
3
+ import { ui, type UiLang } from "../lib/i18n";
3
4
 
4
- export function SiteFooter() {
5
+ /** On the Garden docs pages the footer carries no site-owner line. */
6
+ export function SiteFooter({ docs = false, lang }: { docs?: boolean; lang?: UiLang }) {
7
+ const t = ui(lang);
5
8
  return (
6
9
  <footer className="sh-footer">
7
10
  <div className="sh-foot">
8
11
  <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>
12
+ {docs ? (
13
+ <span className="footer-framework">{t.builtWith[0]}<a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a>{t.builtWith[1]}</span>
14
+ ) : (
15
+ <>
16
+ © {new Date().getFullYear()} {siteConfig.author.name} · {siteConfig.footer}
17
+ <span className="footer-framework"> · {t.builtWith[0]}<a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a>{t.builtWith[1]}</span>
18
+ </>
19
+ )}
11
20
  </p>
12
21
  <div className="sh-foot-links">
13
22
  <a href={githubUrl} rel="me">