hd-garden 0.7.0 → 0.8.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.
Files changed (220) hide show
  1. package/.agents/skills/garden-ui/SKILL.md +26 -19
  2. package/.env.en.example +67 -0
  3. package/README.en.md +57 -0
  4. package/README.md +31 -46
  5. package/app/a2/[slug]/page.tsx +4 -3
  6. package/app/a2/page.tsx +4 -3
  7. package/app/about/page.tsx +8 -9
  8. package/app/admin/page.tsx +7 -6
  9. package/app/admin/preview/[...path]/page.tsx +2 -1
  10. package/app/admin/ui/blocks/page.tsx +2 -1
  11. package/app/admin/ui/catalog/page.tsx +2 -1
  12. package/app/admin/ui/evals/page.tsx +2 -1
  13. package/app/admin/ui/page.tsx +2 -1
  14. package/app/blog/[...path]/page.tsx +14 -5
  15. package/app/blog/page.tsx +4 -2
  16. package/app/columns/[column]/[...path]/page.tsx +17 -18
  17. package/app/columns/page.tsx +12 -8
  18. package/app/components/AdminUiShell.tsx +4 -3
  19. package/app/components/ArticlePager.tsx +32 -0
  20. package/app/components/ArticleReferences.tsx +4 -1
  21. package/app/components/ArticleTags.tsx +3 -2
  22. package/app/components/BlogArchive.tsx +20 -16
  23. package/app/components/Comments.tsx +17 -10
  24. package/app/components/ContentEmptyState.tsx +29 -28
  25. package/app/components/ExternalEmbed.tsx +15 -14
  26. package/app/components/FeedMore.tsx +70 -0
  27. package/app/components/GardenContentGuide.tsx +10 -2
  28. package/app/components/GardenDocsNav.tsx +29 -22
  29. package/app/components/GiscusComments.tsx +2 -1
  30. package/app/components/GithubLiveOverview.tsx +10 -8
  31. package/app/components/GithubProfile.tsx +31 -21
  32. package/app/components/HandbookLangRedirect.tsx +30 -0
  33. package/app/components/HandbookLangSwitch.tsx +53 -0
  34. package/app/components/HandbookView.tsx +270 -0
  35. package/app/components/MarkdownArticle.tsx +39 -9
  36. package/app/components/MarkdownImage.tsx +6 -4
  37. package/app/components/SiteChrome.tsx +11 -2
  38. package/app/components/SiteFooter.tsx +6 -4
  39. package/app/components/SiteHeader.tsx +38 -25
  40. package/app/components/TableOfContents.tsx +15 -9
  41. package/app/components/ThoughtCard.tsx +16 -14
  42. package/app/components/ThoughtFeed.tsx +41 -0
  43. package/app/components/UiBlock.tsx +3 -2
  44. package/app/components/UiLang.tsx +18 -0
  45. package/app/components/Webmentions.tsx +13 -12
  46. package/app/components/WidgetChart.tsx +8 -6
  47. package/app/components/garden/FilePreview.tsx +14 -12
  48. package/app/components/garden/GardenBlock.tsx +10 -1
  49. package/app/components/garden/GardenFile.tsx +10 -8
  50. package/app/components/garden/RefDemo.tsx +62 -0
  51. package/app/components/garden/blocks.tsx +90 -32
  52. package/app/components/garden/chrome.tsx +20 -13
  53. package/app/components/garden/patterns.tsx +497 -0
  54. package/app/garden/[...slug]/page.tsx +11 -130
  55. package/app/garden/en/[...slug]/page.tsx +28 -0
  56. package/app/garden/en/page.tsx +16 -0
  57. package/app/garden/page.tsx +8 -74
  58. package/app/garden-modules.d.ts +3 -0
  59. package/app/globals.css +10 -0
  60. package/app/layout.tsx +2 -4
  61. package/app/lib/a2-meta.ts +26 -3
  62. package/app/lib/content.ts +13 -3
  63. package/app/lib/garden-docs-meta.ts +84 -5
  64. package/app/lib/github-id.mjs +8 -0
  65. package/app/lib/handbook-path.ts +11 -0
  66. package/app/lib/handbook-switch.mjs +12 -0
  67. package/app/lib/i18n.ts +621 -0
  68. package/app/lib/page-alternates.ts +2 -2
  69. package/app/lib/rehype-field-tables.mjs +47 -0
  70. package/app/lib/remark-a2.mjs +18 -8
  71. package/app/lib/remark-link-card.mjs +22 -0
  72. package/app/lib/remark-ref-demos.mjs +42 -0
  73. package/app/lib/tag-filter.ts +9 -2
  74. package/app/not-found.tsx +34 -4
  75. package/app/page.tsx +17 -14
  76. package/app/rss.xml/route.ts +2 -1
  77. package/app/site.config.ts +1 -1
  78. package/app/styles/a2.css +439 -32
  79. package/app/styles/shell.css +21 -2
  80. package/app/tags/page.tsx +7 -5
  81. package/app/thoughts/[slug]/page.tsx +2 -1
  82. package/app/thoughts/page/[n]/page.tsx +44 -0
  83. package/app/thoughts/page.tsx +4 -7
  84. package/app/ui-parity/page.tsx +38 -22
  85. package/bin/garden.mjs +133 -19
  86. package/docs/handbook/ai-writing.md +99 -0
  87. package/docs/handbook/assets/reading-layout-en.svg +26 -0
  88. package/docs/handbook/components/card.md +43 -20
  89. package/docs/handbook/components/chart.md +10 -24
  90. package/docs/handbook/components/choice.md +42 -0
  91. package/docs/handbook/components/compare.md +34 -21
  92. package/docs/handbook/components/figure.md +33 -44
  93. package/docs/handbook/components/file.md +10 -13
  94. package/docs/handbook/components/html.md +10 -28
  95. package/docs/handbook/components/interactive.md +9 -33
  96. package/docs/handbook/components/list.md +65 -0
  97. package/docs/handbook/components/relations.md +95 -0
  98. package/docs/handbook/components/stat.md +10 -26
  99. package/docs/handbook/components/timeline.md +13 -26
  100. package/docs/handbook/components.md +210 -19
  101. package/docs/handbook/config.md +7 -5
  102. package/docs/handbook/deploy.md +2 -2
  103. package/docs/handbook/design.md +23 -15
  104. package/docs/handbook/en/ai-writing.md +99 -0
  105. package/docs/handbook/en/components/card.md +67 -0
  106. package/docs/handbook/en/components/chart.md +38 -0
  107. package/docs/handbook/en/components/choice.md +42 -0
  108. package/docs/handbook/en/components/compare.md +53 -0
  109. package/docs/handbook/en/components/figure.md +59 -0
  110. package/docs/handbook/en/components/file/checklist.md +17 -0
  111. package/docs/handbook/en/components/file.md +29 -0
  112. package/docs/handbook/en/components/html.md +49 -0
  113. package/docs/handbook/en/components/interactive.md +41 -0
  114. package/docs/handbook/en/components/list.md +65 -0
  115. package/docs/handbook/en/components/relations.md +95 -0
  116. package/docs/handbook/en/components/stat.md +39 -0
  117. package/docs/handbook/en/components/timeline.md +37 -0
  118. package/docs/handbook/en/components.md +222 -0
  119. package/docs/handbook/en/config.md +127 -0
  120. package/docs/handbook/en/deploy.md +273 -0
  121. package/docs/handbook/en/design.md +88 -0
  122. package/docs/handbook/en/index.md +192 -0
  123. package/docs/handbook/en/reference/card.md +85 -0
  124. package/docs/handbook/en/reference/chart.md +68 -0
  125. package/docs/handbook/en/reference/choice.md +69 -0
  126. package/docs/handbook/en/reference/compare.md +59 -0
  127. package/docs/handbook/en/reference/figure.md +84 -0
  128. package/docs/handbook/en/reference/file/checklist.md +17 -0
  129. package/docs/handbook/en/reference/file.md +50 -0
  130. package/docs/handbook/en/reference/html.md +62 -0
  131. package/docs/handbook/en/reference/interactive.md +71 -0
  132. package/docs/handbook/en/reference/list.md +72 -0
  133. package/docs/handbook/en/reference/relations.md +113 -0
  134. package/docs/handbook/en/reference/stat.md +60 -0
  135. package/docs/handbook/en/reference/timeline.md +56 -0
  136. package/docs/handbook/en/reference.md +26 -0
  137. package/docs/handbook/en/start.md +92 -0
  138. package/docs/handbook/en/workflow.md +82 -0
  139. package/docs/handbook/en/writing.md +157 -0
  140. package/docs/handbook/index.md +99 -4
  141. package/docs/handbook/reference/card.md +85 -0
  142. package/docs/handbook/reference/chart.md +68 -0
  143. package/docs/handbook/reference/choice.md +69 -0
  144. package/docs/handbook/reference/compare.md +59 -0
  145. package/docs/handbook/reference/figure.md +84 -0
  146. package/docs/handbook/reference/file/checklist.md +17 -0
  147. package/docs/handbook/reference/file.md +50 -0
  148. package/docs/handbook/reference/html.md +62 -0
  149. package/docs/handbook/reference/interactive.md +71 -0
  150. package/docs/handbook/reference/list.md +72 -0
  151. package/docs/handbook/reference/relations.md +113 -0
  152. package/docs/handbook/reference/stat.md +60 -0
  153. package/docs/handbook/reference/timeline.md +56 -0
  154. package/docs/handbook/reference.md +26 -0
  155. package/docs/handbook/start.md +1 -1
  156. package/docs/handbook/workflow.md +6 -5
  157. package/docs/handbook/writing.md +21 -12
  158. package/next.config.ts +6 -0
  159. package/package.json +2 -1
  160. package/public/images/garden/ref-card-book.svg +1 -0
  161. package/public/images/garden/ref-card-lamp.svg +1 -0
  162. package/scripts/build-content.mjs +57 -16
  163. package/scripts/favicon-ico.mjs +9 -6
  164. package/scripts/file-preview.mjs +10 -7
  165. package/scripts/handbook-allowlist.mjs +18 -0
  166. package/scripts/handbook-facts.mjs +339 -11
  167. package/scripts/html-lang.mjs +36 -0
  168. package/scripts/link-assets.mjs +7 -0
  169. package/scripts/new-post.mjs +18 -9
  170. package/scripts/prepare-pages-output.mjs +30 -18
  171. package/scripts/redirects.mjs +17 -6
  172. package/scripts/static-404.mjs +25 -0
  173. package/scripts/ui-cli.mjs +14 -8
  174. package/templates/garden-blocks.md +67 -0
  175. package/templates/garden-snippets.plist +61 -0
  176. package/templates/site/Makefile.en +45 -0
  177. package/templates/site/README.en.md +15 -0
  178. package/templates/site/site.config.en.yaml +55 -0
  179. package/templates/site/site.config.yaml +2 -0
  180. package/templates/skills-en/garden-interactive-chart/SKILL.md +84 -0
  181. package/templates/skills-en/garden-interactive-chart/references/shell.html +49 -0
  182. package/templates/skills-en/garden-interactive-chart/references/style.md +61 -0
  183. package/templates/skills-en/garden-post-review/SKILL.md +63 -0
  184. package/templates/skills-en/garden-ui/SKILL.md +118 -0
  185. package/templates/workflows/pages.en.yml +195 -0
  186. package/templates/workflows/pages.yml +195 -0
  187. package/ui/core/apply.mjs +22 -4
  188. package/ui/core/cli-locale.mjs +67 -0
  189. package/ui/core/fence.mjs +18 -3
  190. package/ui/core/fix-prompt.mjs +18 -15
  191. package/ui/core/garden-fence.mjs +64 -0
  192. package/ui/core/scan.mjs +19 -3
  193. package/ui/garden/adjacent.mjs +21 -0
  194. package/ui/garden/catalog.mjs +205 -4
  195. package/ui/garden/check.mjs +12 -0
  196. package/ui/garden/file-block.mjs +8 -7
  197. package/ui/garden/html-lang.mjs +88 -0
  198. package/ui/garden/markdown-links.mjs +7 -4
  199. package/ui/garden/paging.mjs +30 -0
  200. package/ui/garden/parse.mjs +45 -0
  201. package/ui/garden/relations.mjs +252 -0
  202. package/ui/garden/search-index.mjs +6 -4
  203. package/ui/garden/source-icon.mjs +54 -0
  204. package/ui/parity-fixtures.mjs +77 -0
  205. package/ui/preview/dist/garden-ui.css +9 -9
  206. package/ui/preview/dist/garden-ui.esm.js +206 -169
  207. package/ui/preview/dist/garden-ui.iife.js +206 -169
  208. package/ui/prompt/rules.md +28 -1
  209. package/ui/react/components/chart.tsx +8 -5
  210. package/ui/react/components/display.tsx +7 -4
  211. package/ui/react/components/inputs.tsx +6 -3
  212. package/ui/react/components/layout.tsx +6 -4
  213. package/ui/react/components/media.tsx +7 -4
  214. package/ui/react/components/sandbox.tsx +3 -1
  215. package/ui/react/index.tsx +11 -7
  216. package/ui/react/lang.tsx +89 -0
  217. package/ui/react/parity.tsx +8 -6
  218. package/ui/react/runtime.tsx +3 -1
  219. package/ui/react/ui.css +9 -9
  220. package/worker/index.ts +14 -1
@@ -0,0 +1,497 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect, useId, useRef, useState, type ComponentType, type CSSProperties, type ReactNode } from "react";
4
+ import { sourceIcon } from "../../../ui/garden/source-icon.mjs";
5
+ import { circled, labelFits, legendLine, placeMarks, relationKeyAction, RELATION_COMPACT_WIDTH, RELATION_LABEL_FONT_MIN, relationEdges, relationHeights, relationIndex, relationLayout, relationLines, type RelationEdge as Edge } from "../../../ui/garden/relations.mjs";
6
+ import ReactMarkdown from "react-markdown";
7
+ import {
8
+ Bell,
9
+ BookOpen,
10
+ ChartBar,
11
+ ChatCircle,
12
+ CheckCircle,
13
+ Clock,
14
+ Eye,
15
+ FileText,
16
+ Flag,
17
+ Flask,
18
+ Gear,
19
+ Heart,
20
+ Lightbulb,
21
+ LinkSimple,
22
+ MagnifyingGlass,
23
+ MapPin,
24
+ Question,
25
+ ShieldCheck,
26
+ Star,
27
+ Target,
28
+ User,
29
+ Wallet,
30
+ XCircle,
31
+ type IconProps,
32
+ } from "@phosphor-icons/react/ssr";
33
+ import { useUi, useUiLang } from "../UiLang";
34
+ import { markdownPlugins } from "../../lib/markdown-plugins";
35
+
36
+ function Rich({ text }: { text: string }) {
37
+ return (
38
+ <ReactMarkdown remarkPlugins={markdownPlugins} components={{ p: ({ children }) => <>{children}</> }}>
39
+ {String(text ?? "")}
40
+ </ReactMarkdown>
41
+ );
42
+ }
43
+
44
+ /** Line icons an author can name in `icon:`. Unknown names draw nothing. */
45
+ export const GARDEN_ICONS: Record<string, ComponentType<IconProps>> = {
46
+ search: MagnifyingGlass,
47
+ eye: Eye,
48
+ heart: Heart,
49
+ chat: ChatCircle,
50
+ shield: ShieldCheck,
51
+ wallet: Wallet,
52
+ bell: Bell,
53
+ check: CheckCircle,
54
+ x: XCircle,
55
+ star: Star,
56
+ flag: Flag,
57
+ clock: Clock,
58
+ idea: Lightbulb,
59
+ book: BookOpen,
60
+ link: LinkSimple,
61
+ user: User,
62
+ chart: ChartBar,
63
+ gear: Gear,
64
+ file: FileText,
65
+ pin: MapPin,
66
+ flask: Flask,
67
+ target: Target,
68
+ question: Question,
69
+ };
70
+
71
+ export function GardenIcon({ name, size = 18 }: { name: unknown; size?: number }) {
72
+ const Icon = typeof name === "string" ? GARDEN_ICONS[name.trim().toLowerCase()] : undefined;
73
+ return Icon ? <Icon className="g-ic" size={size} weight="regular" aria-hidden="true" /> : null;
74
+ }
75
+
76
+ const textsOf = (value: unknown) => (value == null ? [] : Array.isArray(value) ? value.map(String) : [String(value)]);
77
+ const itemsOf = (props: Record<string, unknown>) =>
78
+ (Array.isArray(props.items) ? props.items : []).map((item) => (typeof item === "string" ? { text: item } : (item ?? {}) as Record<string, unknown>));
79
+
80
+ /* ---------- list: icon rows (compact card) or icon steps (titled rows) ---------- */
81
+
82
+ export function GardenList({ props }: { props: Record<string, unknown> }) {
83
+ const items = itemsOf(props);
84
+ const titled = items.some((item) => item.title);
85
+ return (
86
+ <figure className={`g-list ${titled ? "g-list-steps" : "g-list-rows"}`}>
87
+ {typeof props.title === "string" ? <p className="g-list-h"><Rich text={props.title} /></p> : null}
88
+ <ul>
89
+ {items.map((item, index) => (
90
+ <li key={index}>
91
+ <span className="g-list-ic"><GardenIcon name={item.icon} size={titled ? 20 : 17} /></span>
92
+ <div className="g-list-b">
93
+ {item.title ? <p className="g-list-t"><Rich text={String(item.title)} /></p> : null}
94
+ {textsOf(item.text).map((text, textIndex) => <p key={textIndex} className="g-list-x"><Rich text={text} /></p>)}
95
+ </div>
96
+ </li>
97
+ ))}
98
+ </ul>
99
+ {typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
100
+ </figure>
101
+ );
102
+ }
103
+
104
+ /* ---------- choice: two or three option cards, pick one ---------- */
105
+
106
+ export function GardenChoice({ props }: { props: Record<string, unknown> }) {
107
+ const items = itemsOf(props);
108
+ const [picked, setPicked] = useState<number | null>(null);
109
+ const base = useId();
110
+ const title = typeof props.title === "string" ? props.title : "";
111
+ const move = (index: number, step: number) => {
112
+ const next = (index + step + count) % count;
113
+ setPicked(next);
114
+ document.getElementById(`${base}-${next}`)?.focus();
115
+ };
116
+ const none = props.none && typeof props.none === "object" ? (props.none as Record<string, unknown>) : null;
117
+ const noneLabel = none && typeof none.label === "string" ? none.label : "";
118
+ const count = items.length + (noneLabel ? 1 : 0);
119
+ const chosen = picked === null ? null : picked < items.length ? items[picked] : none;
120
+ const reply = chosen ? String(chosen.reply ?? "") : "";
121
+ return (
122
+ <figure className="g-choice">
123
+ {title ? <p className="g-choice-h" id={`${base}-h`}><Rich text={title} /></p> : null}
124
+ <div className="g-choice-group" role="radiogroup" aria-labelledby={title ? `${base}-h` : undefined}>
125
+ <div className="g-choice-opts" data-n={items.length}>
126
+ {items.map((item, index) => {
127
+ const on = picked === index;
128
+ return (
129
+ <button
130
+ key={index}
131
+ id={`${base}-${index}`}
132
+ type="button"
133
+ role="radio"
134
+ aria-checked={on}
135
+ tabIndex={on || (picked === null && index === 0) ? 0 : -1}
136
+ className="g-choice-opt"
137
+ onClick={() => setPicked(index)}
138
+ onKeyDown={(event) => {
139
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); move(index, 1); }
140
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") { event.preventDefault(); move(index, -1); }
141
+ }}
142
+ >
143
+ {item.icon ? <span className="g-choice-ic"><GardenIcon name={item.icon} size={26} /></span> : null}
144
+ {item.value != null ? <span className="g-choice-v">{String(item.value)}</span> : null}
145
+ {item.text ? <span className="g-choice-x"><Rich text={String(item.text)} /></span> : null}
146
+ {item.label ? <span className="g-choice-l"><span className="g-choice-dot" aria-hidden="true" />{String(item.label)}</span> : null}
147
+ </button>
148
+ );
149
+ })}
150
+ </div>
151
+ {noneLabel ? (
152
+ <button
153
+ id={`${base}-${items.length}`}
154
+ type="button"
155
+ role="radio"
156
+ aria-checked={picked === items.length}
157
+ tabIndex={picked === items.length ? 0 : -1}
158
+ className="g-choice-none"
159
+ onClick={() => setPicked(items.length)}
160
+ onKeyDown={(event) => {
161
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); move(items.length, 1); }
162
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") { event.preventDefault(); move(items.length, -1); }
163
+ }}
164
+ >{noneLabel}</button>
165
+ ) : null}
166
+ </div>
167
+ {items.some((item) => item.reply) || none?.reply ? <p className="g-choice-reply" aria-live="polite" hidden={!reply}>{reply ? <Rich text={reply} /> : null}</p> : null}
168
+ {typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
169
+ </figure>
170
+ );
171
+ }
172
+
173
+ /* ---------- relations: 3-6 key elements on a circle, with explicit directed links ---------- */
174
+
175
+ type Box = { x: number; y: number; w: number; h: number };
176
+ type Pt = { x: number; y: number };
177
+ type Drawn = { d: string; dashed: boolean; both: boolean; label: string; at: Pt };
178
+
179
+ const center = (box: Box): Pt => ({ x: box.x + box.w / 2, y: box.y + box.h / 2 });
180
+
181
+ /** Where the ray from the box centre towards `to` leaves the box (plus a small gap). */
182
+ function edge(box: Box, to: Pt, gap = 6): Pt {
183
+ const c = center(box);
184
+ const dx = to.x - c.x;
185
+ const dy = to.y - c.y;
186
+ if (!dx && !dy) return c;
187
+ const scale = Math.min(dx ? (box.w / 2 + gap) / Math.abs(dx) : Infinity, dy ? (box.h / 2 + gap) / Math.abs(dy) : Infinity);
188
+ return { x: c.x + dx * scale, y: c.y + dy * scale };
189
+ }
190
+
191
+ type Placed = Drawn & { badge: number };
192
+
193
+ let measureCanvas: HTMLCanvasElement | null = null;
194
+ function textWidth(text: string, font: string) {
195
+ measureCanvas ??= document.createElement("canvas");
196
+ const ctx = measureCanvas.getContext("2d");
197
+ if (!ctx) return text.length * 7;
198
+ ctx.font = font;
199
+ return ctx.measureText(text).width;
200
+ }
201
+
202
+ /** The longest start of `full`, cut after a word, that fits in `lines` lines of `el` with "…" added; null when it all fits. */
203
+ function fitLines(el: HTMLElement, full: string, lines: number): string | null {
204
+ const probe = el.cloneNode(false) as HTMLElement;
205
+ probe.style.cssText = `display:block;-webkit-line-clamp:none;overflow:visible;position:absolute;visibility:hidden;left:0;top:0;width:${el.clientWidth}px`;
206
+ el.parentElement?.appendChild(probe);
207
+ const style = getComputedStyle(probe);
208
+ const line = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.35;
209
+ const fits = (text: string) => { probe.textContent = text; return probe.scrollHeight <= line * lines + 1 && probe.scrollWidth <= probe.clientWidth + 1; };
210
+ let result: string | null = null;
211
+ if (!fits(full)) {
212
+ // CJK text has no spaces: every character is a place to cut; otherwise cut between words.
213
+ const parts = /\s/u.test(full.trim()) ? full.split(/(?<=\s)/u) : [...full];
214
+ let lo = 0;
215
+ let hi = parts.length - 1;
216
+ while (lo < hi) {
217
+ const mid = Math.ceil((lo + hi) / 2);
218
+ if (fits(`${parts.slice(0, mid).join("").trimEnd()}\u2060…`)) lo = mid; else hi = mid - 1;
219
+ }
220
+ if (lo === 0 && parts.length > 1) {
221
+ // Even the first word is too wide: cut inside it as a last resort.
222
+ const chars = [...parts[0].trimEnd()];
223
+ let c = chars.length;
224
+ while (c > 1 && !fits(`${chars.slice(0, c).join("")}\u2060…`)) c -= 1;
225
+ result = `${chars.slice(0, c).join("")}\u2060…`;
226
+ } else result = `${parts.slice(0, Math.max(lo, 1)).join("").trimEnd()}\u2060…`;
227
+ }
228
+ probe.remove();
229
+ return result;
230
+ }
231
+
232
+ export function GardenRelations({ props }: { props: Record<string, unknown> }) {
233
+ const t = useUi();
234
+ const lang = useUiLang();
235
+ const items = itemsOf(props).slice(0, 6); // parse rejects counts outside 3..6
236
+ const titles = items.map((item) => String(item.title ?? ""));
237
+ const edges = relationEdges(props, titles);
238
+ const lines = relationLines(edges, titles);
239
+ const n = items.length;
240
+ const centre = props.center == null ? -1 : relationIndex(props.center, titles);
241
+ const layout = relationLayout(n, centre);
242
+ const ringRef = useRef<HTMLDivElement>(null);
243
+ const [drawn, setDrawn] = useState<Placed[]>([]);
244
+ const [size, setSize] = useState({ w: 0, h: 0 });
245
+ const [compact, setCompact] = useState(false);
246
+ const [open, setOpen] = useState<number | null>(null);
247
+ // Titles and descriptions that do not fit their line limit, cut at a word with "…" (index -> [title, text]).
248
+ const [clipped, setClipped] = useState<Record<number, [string | null, string | null]>>({});
249
+ const fullText = useRef<{ key: string; text: [string, string][] } | null>(null);
250
+ const base = useId().replace(/:/gu, "");
251
+ const edgeKey = JSON.stringify({ edges, centre, n });
252
+
253
+ useEffect(() => {
254
+ const ring = ringRef.current;
255
+ if (!ring) return;
256
+ const { edges: parsed, centre: middle, n: count } = JSON.parse(edgeKey) as { edges: Edge[]; centre: number; n: number };
257
+ let lastWidth = -1;
258
+ const draw = () => {
259
+ const width = ring.clientWidth;
260
+ if (!width || width === lastWidth) return;
261
+ lastWidth = width;
262
+ const small = width < RELATION_COMPACT_WIDTH;
263
+ // Apply the compact styles before measuring, so node sizes match what React renders next.
264
+ const figure = ring.closest("figure");
265
+ if (small) figure?.setAttribute("data-compact", "true");
266
+ else figure?.removeAttribute("data-compact");
267
+ setCompact(small);
268
+ const { spots, outer } = relationLayout(count, middle, small);
269
+ const nodes = [...ring.querySelectorAll<HTMLElement>(":scope > .g-rel-node")];
270
+ // Same layout at every width: place each node by percent of the width, then size the canvas to the nodes.
271
+ nodes.forEach((node, i) => { node.style.left = `${spots[i].x}%`; node.style.width = `${spots[i].w}%`; });
272
+ // Cut long text ourselves, at a word, so the "…" line stays centred (Chrome's line-clamp centres
273
+ // the last line as if the cut words were still there). The CSS clamp stays as the no-script fallback.
274
+ if (!fullText.current || fullText.current.key !== edgeKey) {
275
+ fullText.current = { key: edgeKey, text: nodes.map((node) => [node.querySelector(".g-rel-t")?.textContent ?? "", node.querySelector(".g-rel-x")?.textContent ?? ""]) };
276
+ }
277
+ const cut: Record<number, [string | null, string | null]> = {};
278
+ nodes.forEach((node, i) => {
279
+ const pair: [string | null, string | null] = [null, null];
280
+ (["g-rel-t", "g-rel-x"] as const).forEach((cls, k) => {
281
+ const el = node.querySelector<HTMLElement>(`.${cls}`);
282
+ const full = fullText.current?.text[i][k] ?? "";
283
+ if (!el || !full || getComputedStyle(el).display === "none") return;
284
+ pair[k] = fitLines(el, full, k === 0 ? 2 : small ? 2 : 3);
285
+ });
286
+ if (pair[0] !== null || pair[1] !== null) cut[i] = pair;
287
+ });
288
+ setClipped(cut);
289
+ const minRy = small ? Math.max(90, width * 0.26) : Math.min(200, Math.max(120, width * 0.2)) * (middle >= 0 ? 1.25 : 1);
290
+ const heights = nodes.map((node) => node.offsetHeight);
291
+ const place = (ry: number) => {
292
+ const { ys, height } = relationHeights(spots, heights, small ? 40 : 48, ry);
293
+ nodes.forEach((node, i) => { node.style.top = `${ys[i]}px`; });
294
+ ring.style.height = `${Math.ceil(height)}px`;
295
+ const frame = ring.getBoundingClientRect();
296
+ const boxes = nodes.map((node) => {
297
+ const r = node.getBoundingClientRect();
298
+ return { x: r.left - frame.left, y: r.top - frame.top, w: r.width, h: r.height };
299
+ });
300
+ return { frame, boxes };
301
+ };
302
+ // Every line keeps a visible stretch between its two boxes (24px narrow, 48px wide):
303
+ // open the ring up a step at a time until the shortest line is long enough.
304
+ const target = small ? 24 : 48;
305
+ const shortest = (boxes: Box[]) => Math.min(Infinity, ...parsed.map((link) => {
306
+ const a = edge(boxes[link.from], center(boxes[link.to]), link.both ? (small ? 7 : 8) : 6);
307
+ const b = edge(boxes[link.to], center(boxes[link.from]), small ? 7 : 8);
308
+ return Math.hypot(b.x - a.x, b.y - a.y);
309
+ }));
310
+ let ry = minRy;
311
+ let { frame, boxes } = place(ry);
312
+ for (let step = 0; step < 10; step += 1) {
313
+ const short = shortest(boxes);
314
+ if (short >= target) break;
315
+ ry += Math.max(12, (target - short) * 1.5);
316
+ ({ frame, boxes } = place(ry));
317
+ }
318
+ const hub = { x: frame.width / 2, y: frame.height / 2 };
319
+ // Edges between the same pair of nodes each get their own arc, so a dashed line never hides under a solid one.
320
+ const pairKey = (l: Edge) => (l.from < l.to ? `${l.from}-${l.to}` : `${l.to}-${l.from}`);
321
+ const pairCount = new Map<string, number>();
322
+ const pairIndex = parsed.map((l) => { const k = pairKey(l); const i = pairCount.get(k) ?? 0; pairCount.set(k, i + 1); return i; });
323
+ const geometry = parsed.map((link, index) => {
324
+ const from = boxes[link.from];
325
+ const to = boxes[link.to];
326
+ const cf = center(from);
327
+ const ct = center(to);
328
+ const endGap = small ? 7 : 8;
329
+ const gapStart = link.both ? endGap : 6;
330
+ const rf = spots[link.from].ring;
331
+ const rt = spots[link.to].ring;
332
+ const neighbours = rf >= 0 && rt >= 0 && outer > 3 && (Math.abs(rf - rt) === 1 || Math.abs(rf - rt) === outer - 1);
333
+ const total = pairCount.get(pairKey(link)) ?? 1;
334
+ const mid = { x: (cf.x + ct.x) / 2, y: (cf.y + ct.y) / 2 };
335
+ const span = Math.hypot(ct.x - cf.x, ct.y - cf.y) || 1;
336
+ // Normal in a direction that does not depend on which end is "from", so pair offsets spread apart.
337
+ const lo = link.from < link.to ? cf : ct;
338
+ const hi = link.from < link.to ? ct : cf;
339
+ let nx = -(hi.y - lo.y) / span;
340
+ let ny = (hi.x - lo.x) / span;
341
+ let bow = 0;
342
+ if (neighbours) {
343
+ // Next to each other on the outer ring: a gentle arc that bows outward, at every width.
344
+ if (nx * (mid.x - hub.x) + ny * (mid.y - hub.y) < 0) { nx = -nx; ny = -ny; }
345
+ bow = Math.min(small ? 22 : 40, span / 4);
346
+ }
347
+ if (total > 1) bow += (pairIndex[index] - (total - 1) / 2) * (small ? 30 : 40) * 2;
348
+ if (!bow) {
349
+ const a = edge(from, ct, gapStart);
350
+ const b = edge(to, cf, endGap);
351
+ const at = (k: number) => ({ x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k });
352
+ return { d: `M${a.x},${a.y} L${b.x},${b.y}`, a, b, at, link };
353
+ }
354
+ const c = { x: mid.x + nx * bow, y: mid.y + ny * bow };
355
+ const a = edge(from, c, gapStart);
356
+ const b = edge(to, c, endGap);
357
+ const at = (k: number) => ({ x: (1 - k) ** 2 * a.x + 2 * (1 - k) * k * c.x + k ** 2 * b.x, y: (1 - k) ** 2 * a.y + 2 * (1 - k) * k * c.y + k ** 2 * b.y });
358
+ return { d: `M${a.x},${a.y} Q${c.x},${c.y} ${b.x},${b.y}`, a, b, at, link };
359
+ });
360
+ // Labels that do not fit on their line at 12px become circled numbers listed under the graph.
361
+ // Every mark gets a spot that overlaps no node and no other mark.
362
+ const font = `500 ${RELATION_LABEL_FONT_MIN + 0.5}px ${getComputedStyle(ring).fontFamily}`;
363
+ const marks = geometry.map((g) => {
364
+ if (!g.link.label) return { a: g.a, b: g.b, at: g.at, w: 0, h: 0, badge: false };
365
+ const size = { width: textWidth(g.link.label, font) + 12, height: 20 };
366
+ return { a: g.a, b: g.b, at: g.at, w: size.width, h: size.height, badge: !labelFits(size, g.a, g.b, g.at(0.5), []) };
367
+ });
368
+ const spots2 = placeMarks(marks, boxes, { w: frame.width, h: frame.height });
369
+ let badge = 0;
370
+ setDrawn(geometry.map((g, i) => {
371
+ const spot = spots2[i];
372
+ const number = spot?.badge ? badge++ : -1;
373
+ return { d: g.d, dashed: g.link.dashed, both: g.link.both, label: g.link.label, at: spot ?? g.at(0.5), badge: number };
374
+ }));
375
+ setSize({ w: frame.width, h: frame.height });
376
+ };
377
+ draw();
378
+ if (typeof ResizeObserver === "undefined") return;
379
+ const observer = new ResizeObserver(() => draw());
380
+ observer.observe(ring);
381
+ return () => observer.disconnect();
382
+ }, [edgeKey]);
383
+
384
+ const label = typeof props.title === "string" ? props.title : t.relations;
385
+ const legend = drawn.map((line, index) => ({ line, edge: edges[index] })).filter(({ line }) => line.badge >= 0);
386
+ return (
387
+ <figure
388
+ className="g-rel"
389
+ role="group"
390
+ aria-label={label}
391
+ data-compact={compact ? "true" : undefined}
392
+ onKeyDown={(event) => {
393
+ // Esc closes the details panel and puts focus back on the node that opened it.
394
+ const action = relationKeyAction(event.key, open);
395
+ if (!action.close) return;
396
+ event.preventDefault();
397
+ setOpen(null);
398
+ if (action.focus !== null) ringRef.current?.querySelectorAll<HTMLElement>(":scope > .g-rel-node")[action.focus]?.focus();
399
+ }}
400
+ >
401
+ {typeof props.title === "string" ? <p className="g-rel-h"><Rich text={props.title} /></p> : null}
402
+ <div className="g-rel-ring" ref={ringRef} data-n={n} data-center={layout.centre >= 0 ? "true" : undefined}>
403
+ {items.map((item, index) => {
404
+ const spot = layout.spots[index];
405
+ return (
406
+ <button
407
+ type="button"
408
+ className="g-rel-node"
409
+ key={index}
410
+ data-i={index + 1}
411
+ data-center={index === layout.centre ? "true" : undefined}
412
+ aria-expanded={open === index}
413
+ aria-controls={`${base}-d`}
414
+ tabIndex={0}
415
+ onClick={() => setOpen(open === index ? null : index)}
416
+ style={{ "--x": `${spot.x}%`, "--y": `${50 + spot.s * 38}%`, "--w": `${spot.w}%` } as CSSProperties}
417
+ >
418
+ <span className="g-rel-t">{clipped[index]?.[0] ?? <Rich text={titles[index]} />}</span>
419
+ {item.text ? <span className="g-rel-x">{clipped[index]?.[1] ?? <Rich text={String(item.text)} />}</span> : null}
420
+ {lines[index].length ? (
421
+ <span className="g-rel-out">
422
+ {lines[index].map((line, k) => (
423
+ <span key={k}>
424
+ {k ? " · " : null}
425
+ {line.mark} {line.to}{line.label ? ` ${line.label}` : ""}
426
+ </span>
427
+ ))}
428
+ </span>
429
+ ) : null}
430
+ </button>
431
+ );
432
+ })}
433
+ {size.w ? (
434
+ <svg className="g-rel-svg" width={size.w} height={size.h} viewBox={`0 0 ${size.w} ${size.h}`} aria-hidden="true">
435
+ <defs>
436
+ <marker id={`${base}-a`} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
437
+ <path d="M1,1 L9,5 L1,9 Z" />
438
+ </marker>
439
+ </defs>
440
+ {drawn.map((line, index) => (
441
+ <path
442
+ key={index}
443
+ d={line.d}
444
+ className={line.dashed ? "dash" : undefined}
445
+ markerEnd={`url(#${base}-a)`}
446
+ markerStart={line.both ? `url(#${base}-a)` : undefined}
447
+ />
448
+ ))}
449
+ </svg>
450
+ ) : null}
451
+ {drawn.map((line, index) => line.label ? (
452
+ <span
453
+ key={`l${index}`}
454
+ className={line.badge >= 0 ? "g-rel-label g-rel-badge" : "g-rel-label"}
455
+ aria-hidden="true"
456
+ style={{ left: `${line.at.x}px`, top: `${line.at.y}px` }}
457
+ >{line.badge >= 0 ? circled(line.badge) : line.label}</span>
458
+ ) : null)}
459
+ </div>
460
+ {open !== null ? (
461
+ <div className="g-rel-detail" id={`${base}-d`} aria-live="polite">
462
+ <span className="g-rel-detail-t"><Rich text={titles[open]} /></span>
463
+ {items[open]?.text ? <span className="g-rel-detail-x"><Rich text={String(items[open].text)} /></span> : null}
464
+ </div>
465
+ ) : null}
466
+ {legend.length ? (
467
+ <ol className="g-rel-legend" aria-label={t.relationsLegend}>
468
+ {legend.map(({ line, edge: e }) => <li key={line.badge}>{legendLine(line.badge, e, titles, lang === "en" ? "en" : "zh")}</li>)}
469
+ </ol>
470
+ ) : null}
471
+ {typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
472
+ </figure>
473
+ );
474
+ }
475
+
476
+ export function VerdictIcon({ verdict }: { verdict: string }): ReactNode {
477
+ if (verdict === "good") return <CheckCircle className="g-ic g-verdict-ic good" size={20} aria-hidden="true" />;
478
+ if (verdict === "bad") return <XCircle className="g-ic g-verdict-ic bad" size={20} aria-hidden="true" />;
479
+ return null;
480
+ }
481
+
482
+ /** The site's mark: its build-time favicon, else the registrable domain's first letter. */
483
+ type Favicons = Record<string, { file?: string } | string>;
484
+ const FaviconContext = createContext<Favicons>({});
485
+
486
+ /** The build-time favicons, handed down from the server so client blocks match inline links. */
487
+ export function FaviconProvider({ favicons, children }: { favicons?: Favicons; children: ReactNode }) {
488
+ return <FaviconContext.Provider value={favicons ?? {}}>{children}</FaviconContext.Provider>;
489
+ }
490
+
491
+ export function SourceMark({ href, icon }: { href: string; icon?: string }) {
492
+ const mark = sourceIcon(href, useContext(FaviconContext), icon);
493
+ return mark.src
494
+ // eslint-disable-next-line @next/next/no-img-element -- a 14px site icon
495
+ ? <img className="g-src-fav" src={mark.src} alt="" width={14} height={14} decoding="async" aria-hidden="true" />
496
+ : <span className="g-src-ic" aria-hidden="true">{mark.letter}</span>;
497
+ }
@@ -1,33 +1,10 @@
1
- import Link from "next/link";
2
- import type { Metadata } from "next";
3
1
  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";
2
+ import { HandbookDoc, handbookDocMetadata } from "../../components/HandbookView";
3
+ import { gardenDocs } from "../../lib/content";
4
+ import { siteLang } from "../../lib/i18n";
5
+ import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
6
+
7
+ const find = (slug: string[]) => gardenDocs.find((doc) => doc.path === slug.join("/"));
31
8
 
32
9
  export function generateStaticParams() {
33
10
  return staticParams(
@@ -36,113 +13,17 @@ export function generateStaticParams() {
36
13
  );
37
14
  }
38
15
 
39
- export async function generateMetadata({
40
- params,
41
- }: {
42
- params: Promise<{ slug: string[] }>;
43
- }) {
16
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }) {
44
17
  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;
18
+ return handbookDocMetadata(siteLang, find(slug));
60
19
  }
61
20
 
62
- export default async function GardenDocPage({
63
- params,
64
- }: {
65
- params: Promise<{ slug: string[] }>;
66
- }) {
21
+ export default async function GardenDocPage({ params }: { params: Promise<{ slug: string[] }> }) {
67
22
  const { slug } = await params;
68
- const doc = findGardenDoc(slug.join("/"));
23
+ const doc = find(slug);
69
24
  if (!doc) {
70
25
  if (isStaticPlaceholder(slug)) return null;
71
26
  notFound();
72
27
  }
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
- );
28
+ return <HandbookDoc lang={siteLang} doc={doc} />;
148
29
  }