hd-garden 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.agents/skills/garden-ui/SKILL.md +2 -2
  2. package/.env.en.example +67 -0
  3. package/README.en.md +2 -2
  4. package/README.md +2 -2
  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 +7 -6
  15. package/app/blog/page.tsx +4 -2
  16. package/app/columns/[column]/[...path]/page.tsx +8 -7
  17. package/app/columns/page.tsx +12 -8
  18. package/app/components/AdminUiShell.tsx +4 -3
  19. package/app/components/ArticleMeasure.tsx +6 -3
  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 +16 -10
  24. package/app/components/ContentEmptyState.tsx +29 -28
  25. package/app/components/ExternalEmbed.tsx +15 -14
  26. package/app/components/GardenContentGuide.tsx +10 -2
  27. package/app/components/GardenDocsNav.tsx +4 -2
  28. package/app/components/GiscusComments.tsx +2 -1
  29. package/app/components/GithubLiveOverview.tsx +10 -8
  30. package/app/components/GithubProfile.tsx +31 -21
  31. package/app/components/HandbookLangSwitch.tsx +8 -4
  32. package/app/components/HandbookView.tsx +92 -9
  33. package/app/components/MarkdownArticle.tsx +31 -7
  34. package/app/components/MermaidDiagram.tsx +4 -3
  35. package/app/components/SiteChrome.tsx +3 -3
  36. package/app/components/SiteFooter.tsx +1 -1
  37. package/app/components/SiteHeader.tsx +13 -4
  38. package/app/components/ThoughtCard.tsx +16 -14
  39. package/app/components/UiBlock.tsx +3 -2
  40. package/app/components/Webmentions.tsx +11 -11
  41. package/app/components/WidgetChart.tsx +8 -6
  42. package/app/components/garden/FilePreview.tsx +2 -2
  43. package/app/components/garden/GardenBlock.tsx +7 -0
  44. package/app/components/garden/GardenFile.tsx +2 -2
  45. package/app/components/garden/RefDemo.tsx +62 -0
  46. package/app/components/garden/blocks.tsx +56 -8
  47. package/app/components/garden/patterns.tsx +497 -0
  48. package/app/garden/[...slug]/page.tsx +3 -2
  49. package/app/garden/en/page.tsx +7 -7
  50. package/app/garden/page.tsx +3 -2
  51. package/app/globals.css +49 -1
  52. package/app/lib/a2-meta.ts +26 -3
  53. package/app/lib/content.ts +2 -2
  54. package/app/lib/garden-docs-meta.ts +24 -0
  55. package/app/lib/github-id.mjs +8 -0
  56. package/app/lib/handbook-switch.mjs +12 -0
  57. package/app/lib/i18n.ts +401 -2
  58. package/app/lib/rehype-field-tables.mjs +47 -0
  59. package/app/lib/remark-a2.mjs +18 -8
  60. package/app/lib/remark-link-card.mjs +22 -0
  61. package/app/lib/remark-ref-demos.mjs +42 -0
  62. package/app/lib/tag-filter.ts +9 -2
  63. package/app/not-found.tsx +32 -4
  64. package/app/page.tsx +17 -14
  65. package/app/rss.xml/route.ts +2 -1
  66. package/app/styles/a2.css +338 -0
  67. package/app/styles/shell.css +15 -2
  68. package/app/tags/page.tsx +7 -5
  69. package/app/thoughts/[slug]/page.tsx +2 -1
  70. package/app/thoughts/page/[n]/page.tsx +1 -1
  71. package/app/thoughts/page.tsx +2 -1
  72. package/app/ui-parity/page.tsx +38 -22
  73. package/bin/garden.mjs +129 -19
  74. package/docs/handbook/ai-writing.md +2 -2
  75. package/docs/handbook/assets/reading-layout-en.svg +26 -0
  76. package/docs/handbook/components/card.md +43 -22
  77. package/docs/handbook/components/chart.md +10 -26
  78. package/docs/handbook/components/choice.md +42 -0
  79. package/docs/handbook/components/compare.md +32 -17
  80. package/docs/handbook/components/figure.md +33 -46
  81. package/docs/handbook/components/file.md +10 -15
  82. package/docs/handbook/components/html.md +10 -30
  83. package/docs/handbook/components/interactive.md +9 -35
  84. package/docs/handbook/components/list.md +65 -0
  85. package/docs/handbook/components/relations.md +95 -0
  86. package/docs/handbook/components/stat.md +10 -28
  87. package/docs/handbook/components/timeline.md +13 -28
  88. package/docs/handbook/components.md +210 -19
  89. package/docs/handbook/config.md +5 -3
  90. package/docs/handbook/deploy.md +1 -1
  91. package/docs/handbook/design.md +20 -12
  92. package/docs/handbook/en/ai-writing.md +2 -2
  93. package/docs/handbook/en/components/card.md +42 -21
  94. package/docs/handbook/en/components/chart.md +10 -26
  95. package/docs/handbook/en/components/choice.md +42 -0
  96. package/docs/handbook/en/components/compare.md +30 -15
  97. package/docs/handbook/en/components/figure.md +29 -42
  98. package/docs/handbook/en/components/file.md +9 -14
  99. package/docs/handbook/en/components/html.md +9 -29
  100. package/docs/handbook/en/components/interactive.md +8 -34
  101. package/docs/handbook/en/components/list.md +65 -0
  102. package/docs/handbook/en/components/relations.md +95 -0
  103. package/docs/handbook/en/components/stat.md +9 -27
  104. package/docs/handbook/en/components/timeline.md +9 -24
  105. package/docs/handbook/en/components.md +211 -19
  106. package/docs/handbook/en/config.md +32 -30
  107. package/docs/handbook/en/deploy.md +12 -12
  108. package/docs/handbook/en/design.md +19 -12
  109. package/docs/handbook/en/index.md +2 -2
  110. package/docs/handbook/en/reference/card.md +85 -0
  111. package/docs/handbook/en/reference/chart.md +68 -0
  112. package/docs/handbook/en/reference/choice.md +69 -0
  113. package/docs/handbook/en/reference/compare.md +59 -0
  114. package/docs/handbook/en/reference/figure.md +84 -0
  115. package/docs/handbook/en/reference/file/checklist.md +17 -0
  116. package/docs/handbook/en/reference/file.md +50 -0
  117. package/docs/handbook/en/reference/html.md +62 -0
  118. package/docs/handbook/en/reference/interactive.md +71 -0
  119. package/docs/handbook/en/reference/list.md +72 -0
  120. package/docs/handbook/en/reference/relations.md +113 -0
  121. package/docs/handbook/en/reference/stat.md +60 -0
  122. package/docs/handbook/en/reference/timeline.md +56 -0
  123. package/docs/handbook/en/reference.md +26 -0
  124. package/docs/handbook/en/start.md +2 -2
  125. package/docs/handbook/en/workflow.md +6 -5
  126. package/docs/handbook/en/writing.md +3 -2
  127. package/docs/handbook/index.md +1 -1
  128. package/docs/handbook/reference/card.md +85 -0
  129. package/docs/handbook/reference/chart.md +68 -0
  130. package/docs/handbook/reference/choice.md +69 -0
  131. package/docs/handbook/reference/compare.md +59 -0
  132. package/docs/handbook/reference/figure.md +84 -0
  133. package/docs/handbook/reference/file/checklist.md +17 -0
  134. package/docs/handbook/reference/file.md +50 -0
  135. package/docs/handbook/reference/html.md +62 -0
  136. package/docs/handbook/reference/interactive.md +71 -0
  137. package/docs/handbook/reference/list.md +72 -0
  138. package/docs/handbook/reference/relations.md +113 -0
  139. package/docs/handbook/reference/stat.md +60 -0
  140. package/docs/handbook/reference/timeline.md +56 -0
  141. package/docs/handbook/reference.md +26 -0
  142. package/docs/handbook/start.md +1 -1
  143. package/docs/handbook/workflow.md +6 -5
  144. package/docs/handbook/writing.md +3 -2
  145. package/next.config.ts +6 -0
  146. package/package.json +2 -1
  147. package/public/images/garden/ref-card-book.svg +1 -0
  148. package/public/images/garden/ref-card-lamp.svg +1 -0
  149. package/scripts/build-content.mjs +37 -16
  150. package/scripts/favicon-ico.mjs +9 -6
  151. package/scripts/file-preview.mjs +10 -7
  152. package/scripts/handbook-allowlist.mjs +18 -0
  153. package/scripts/handbook-facts.mjs +305 -11
  154. package/scripts/link-assets.mjs +7 -0
  155. package/scripts/new-post.mjs +18 -9
  156. package/scripts/prepare-pages-output.mjs +30 -18
  157. package/scripts/redirects.mjs +17 -6
  158. package/templates/garden-blocks.md +67 -0
  159. package/templates/garden-snippets.plist +61 -0
  160. package/templates/site/Makefile.en +45 -0
  161. package/templates/site/README.en.md +15 -0
  162. package/templates/site/site.config.en.yaml +55 -0
  163. package/templates/skills-en/garden-interactive-chart/SKILL.md +84 -0
  164. package/templates/skills-en/garden-interactive-chart/references/shell.html +49 -0
  165. package/templates/skills-en/garden-interactive-chart/references/style.md +61 -0
  166. package/templates/skills-en/garden-post-review/SKILL.md +63 -0
  167. package/templates/skills-en/garden-ui/SKILL.md +118 -0
  168. package/templates/workflows/pages.en.yml +195 -0
  169. package/templates/workflows/pages.yml +195 -0
  170. package/ui/garden/catalog.mjs +205 -4
  171. package/ui/garden/check.mjs +6 -0
  172. package/ui/garden/file-block.mjs +8 -7
  173. package/ui/garden/html-lang.mjs +73 -1
  174. package/ui/garden/markdown-links.mjs +7 -4
  175. package/ui/garden/parse.mjs +45 -0
  176. package/ui/garden/relations.mjs +252 -0
  177. package/ui/garden/search-index.mjs +6 -4
  178. package/ui/garden/source-icon.mjs +54 -0
  179. package/ui/parity-fixtures.mjs +77 -0
  180. package/ui/preview/dist/garden-ui.esm.js +41 -4
  181. package/ui/preview/dist/garden-ui.iife.js +41 -4
  182. package/ui/prompt/rules.md +28 -1
  183. package/ui/react/components/chart.tsx +1 -1
  184. package/ui/react/components/display.tsx +1 -1
  185. package/ui/react/lang.tsx +4 -0
  186. package/ui/react/parity.tsx +8 -6
  187. package/worker/index.ts +8 -2
@@ -4,7 +4,7 @@
4
4
  * Names describe what the reader sees. English only.
5
5
  */
6
6
 
7
- export const GARDEN_NAMES = ["card", "stat", "compare", "timeline", "chart", "figure", "html", "interactive", "file"];
7
+ export const GARDEN_NAMES = ["card", "stat", "compare", "timeline", "chart", "figure", "html", "interactive", "file", "relations", "list", "choice"];
8
8
 
9
9
  export const LEGACY_FENCES = {
10
10
  embed: { name: "card", suggestion: "`embed` 已改为 `garden card`" },
@@ -19,7 +19,8 @@ export const CATALOG = {
19
19
  label: "资源卡",
20
20
  when: "一篇论文、一个仓库或一本书,读者需要标题、链接和一句说明。",
21
21
  whenNot: "正文里已经有这句话时,不要再包一张卡。",
22
- keys: ["url", "title", "description", "image", "alt", "points", "meta", "text", "source", "site", "items"],
22
+ keys: ["url", "title", "description", "image", "alt", "points", "meta", "text", "source", "site", "items", "takeaway", "cards"],
23
+ itemKeys: ["url", "title", "description", "image", "alt", "meta", "text", "source", "takeaway"],
23
24
  example: `\`\`\`garden card
24
25
  url: https://arxiv.org/abs/2309.06180
25
26
  title: PagedAttention
@@ -46,7 +47,7 @@ items:
46
47
  when: "两三件东西并排,每格约 60 字以内。原文是「X:Y」列表时,每格写成一行带引号的「X:Y」。",
47
48
  whenNot: "任何一格超过约 60 字时,用原文的列表,不要并排。",
48
49
  keys: ["items"],
49
- itemKeys: ["title", "text"],
50
+ itemKeys: ["title", "text", "verdict"],
50
51
  example: `\`\`\`garden compare
51
52
  items:
52
53
  - title: 自旋锁
@@ -146,6 +147,77 @@ note: 一句话说明里面有什么
146
147
  \`\`\``,
147
148
  triggers: ["gfile"],
148
149
  },
150
+ relations: {
151
+ label: "关系图",
152
+ when: "三到六个关键要素,读者需要看清谁影响谁、哪些是双向的;首尾相接的循环也用它,把每一步写成一条 link。",
153
+ whenNot: "要素更多、有分支或先后顺序时,用 ```mermaid 流程图;只走一遍的步骤用 garden list。",
154
+ keys: ["title", "items", "center", "links", "caption"],
155
+ itemKeys: ["title", "text"],
156
+ linkKeys: ["from", "to", "style", "direction", "label"],
157
+ example: `\`\`\`garden relations
158
+ items:
159
+ - title: 观察
160
+ text: 记下发生了什么
161
+ - title: 假设
162
+ text: 猜一个原因
163
+ - title: 实验
164
+ text: 只改一个变量
165
+ - title: 复盘
166
+ text: 留下能复用的结论
167
+ links:
168
+ - from: 1
169
+ to: 2
170
+ - from: 2
171
+ to: 3
172
+ - from: 3
173
+ to: 4
174
+ - from: 4
175
+ to: 1
176
+ - from: 4
177
+ to: 2
178
+ style: dashed
179
+ label: 改写
180
+ caption: 复盘的结论会改写下一轮的假设。
181
+ \`\`\``,
182
+ triggers: ["grel"],
183
+ },
184
+ list: {
185
+ label: "图标列表",
186
+ when: "几条并列的行为、信号或做法,每条配一个线条图标;带标题时每条可以写几段说明。",
187
+ whenNot: "条目之间有先后顺序或要编号时,用普通有序列表。",
188
+ keys: ["title", "items", "caption"],
189
+ itemKeys: ["icon", "title", "text"],
190
+ example: `\`\`\`garden list
191
+ items:
192
+ - icon: search
193
+ text: 搜索「降噪耳机」
194
+ - icon: eye
195
+ text: 看了 5 个商品详情
196
+ \`\`\``,
197
+ triggers: ["glist"],
198
+ },
199
+ choice: {
200
+ label: "选择卡",
201
+ when: "给读者两三个具体选项让他选一个,每个选项有图标、一个醒目的数字和一句说明。",
202
+ whenNot: "选项会改变后面的计算结果时,写成想法,编译成 interactive。",
203
+ keys: ["title", "items", "none", "caption"],
204
+ itemKeys: ["icon", "value", "text", "label", "reply"],
205
+ example: `\`\`\`garden choice
206
+ title: 你更倾向哪一种?
207
+ items:
208
+ - icon: shield
209
+ value: ¥2,950
210
+ text: 原装,价格略高
211
+ label: 更看重可靠
212
+ - icon: wallet
213
+ value: ¥2,450
214
+ text: 换过电池,价格更低
215
+ label: 更看重价格
216
+ none:
217
+ label: 都不合适
218
+ \`\`\``,
219
+ triggers: ["gchoice"],
220
+ },
149
221
  };
150
222
 
151
223
  export const IDEA = {
@@ -201,7 +273,136 @@ export function suggestName(name) {
201
273
  return best;
202
274
  }
203
275
 
204
- export function cheatSheet() {
276
+ export const IDEA_EN = {
277
+ label: "Idea",
278
+ when: "When you are not sure which component to use yet, write one sentence about what you want the reader to see.",
279
+ whenNot: "Must be compiled before publishing. Fine to leave in drafts.",
280
+ example: `\`\`\`garden
281
+ Let the reader see...; show how...
282
+ \`\`\``,
283
+ triggers: ["gidea"],
284
+ };
285
+
286
+ export const CATALOG_EXAMPLES_EN = {
287
+ card: `\`\`\`garden card
288
+ url: https://arxiv.org/abs/2309.06180
289
+ title: PagedAttention
290
+ description: Paged KV cache management in vLLM.
291
+ \`\`\``,
292
+ stat: `\`\`\`garden stat
293
+ items:
294
+ - label: Llama-2-13B · 4K context
295
+ value: 3.2
296
+ unit: GiB
297
+ source: https://arxiv.org/abs/2309.06180
298
+ \`\`\``,
299
+ compare: `\`\`\`garden compare
300
+ items:
301
+ - title: Spinlock
302
+ text: Busy-waits when the critical section is tiny; never sleeps.
303
+ - title: Mutex
304
+ text: Sleeps under contention to avoid burning CPU cycles.
305
+ \`\`\``,
306
+ timeline: `\`\`\`garden timeline
307
+ items:
308
+ - date: 2015
309
+ title: Concurrent mark
310
+ text: Pause times drop significantly.
311
+ - date: 2017
312
+ title: Stack rescan moved out of STW
313
+ text: Pauses typically stay under 100 microseconds.
314
+ - date: 2022
315
+ title: Soft memory limit
316
+ text: Runtime adds GOMEMLIMIT.
317
+ \`\`\``,
318
+ chart: `\`\`\`garden chart
319
+ type: bar
320
+ title: Sample latency
321
+ x: Option
322
+ series:
323
+ - name: Latency
324
+ data: [120, 85]
325
+ unit: ms
326
+ \`\`\``,
327
+ figure: `\`\`\`garden figure
328
+ image: ./figure.svg
329
+ parts:
330
+ - name: All
331
+ text: The full diagram.
332
+ - name: Mark
333
+ text: This step only marks reachable objects.
334
+ \`\`\``,
335
+ html: `\`\`\`garden html
336
+ src: ./demo.html
337
+ title: Diagram
338
+ \`\`\``,
339
+ interactive: `\`\`\`garden interactive #kv-memory
340
+ caption: KV memory scales linearly with both batch size and sequence length.
341
+ intent: |
342
+ Let the reader adjust batch size and inspect KV memory.
343
+ ---
344
+ <State batch={1} />
345
+ \`\`\``,
346
+ file: `\`\`\`garden file
347
+ src: ./post-name/spec.zip
348
+ note: One-line summary of what is inside
349
+ \`\`\``,
350
+ relations: `\`\`\`garden relations
351
+ items:
352
+ - title: Observe
353
+ text: Note what happened
354
+ - title: Hypothesize
355
+ text: Guess one cause
356
+ - title: Experiment
357
+ text: Change one variable
358
+ - title: Review
359
+ text: Keep a reusable lesson
360
+ links:
361
+ - from: 1
362
+ to: 2
363
+ - from: 2
364
+ to: 3
365
+ - from: 3
366
+ to: 4
367
+ - from: 4
368
+ to: 1
369
+ - from: 4
370
+ to: 2
371
+ style: dashed
372
+ label: revises
373
+ caption: The review revises the next round's hypothesis.
374
+ \`\`\``,
375
+ list: `\`\`\`garden list
376
+ items:
377
+ - icon: search
378
+ text: Searched for "noise-cancelling headphones"
379
+ - icon: eye
380
+ text: Viewed 5 product pages
381
+ \`\`\``,
382
+ choice: `\`\`\`garden choice
383
+ title: Which would you pick?
384
+ items:
385
+ - icon: shield
386
+ value: $415
387
+ text: Original, a bit pricier
388
+ label: Reliability first
389
+ - icon: wallet
390
+ value: $345
391
+ text: Battery replaced, cheaper
392
+ label: Price first
393
+ none:
394
+ label: Neither fits
395
+ \`\`\``,
396
+ };
397
+
398
+ export function cheatSheet(lang = "zh") {
399
+ if (lang === "en") {
400
+ const blocks = [
401
+ IDEA_EN.example,
402
+ ...GARDEN_NAMES.map((name) => CATALOG_EXAMPLES_EN[name] ?? CATALOG[name].example),
403
+ ];
404
+ return ["<!-- Component cheat sheet. Copy the block you need and delete this comment.", ...blocks.flatMap((example) => ["", example]), "-->"].join("\n");
405
+ }
205
406
  const blocks = [
206
407
  IDEA.example,
207
408
  ...GARDEN_NAMES.map((name) => CATALOG[name].example),
@@ -24,6 +24,8 @@ function cellText(item) {
24
24
 
25
25
  function compareTooLong(props) {
26
26
  const items = Array.isArray(props?.items) ? props.items : [];
27
+ // Good/bad verdicts stack one under another, so a longer quote still reads well.
28
+ if (items.some((item) => item && typeof item === "object" && (item.verdict === "good" || item.verdict === "bad"))) return false;
27
29
  return items.some((item) => [...cellText(item).trim()].length > COMPARE_LIMIT);
28
30
  }
29
31
 
@@ -84,6 +86,10 @@ export function checkGardenMarkdown(markdown, { draft = false, title = "", title
84
86
  push(fence.line, "garden-yaml", block.error.message, allowed.length ? `可用的键:${allowed.join("、")}` : "检查缩进和冒号。");
85
87
  return;
86
88
  }
89
+ if (block.error?.code === "garden-relations") {
90
+ push(fence.line, "garden-relations", block.error.message, "要素更多或有分支:```mermaid flowchart;一次走完的步骤:garden list。");
91
+ return;
92
+ }
87
93
  if (block.name === "file") {
88
94
  if (block.props.resolved !== undefined) {
89
95
  push(fence.line, "garden-file", "resolved 由构建生成,不要手写。", "删掉 resolved,只写 src、title、note 或 files。");
@@ -69,10 +69,10 @@ export function fileName(src) {
69
69
  }
70
70
  }
71
71
 
72
- /** Format label: the extension in capitals (MD, HTML, PDF, ZIP, CSV …), or 文件 when there is none. */
73
- export function formatLabel(src) {
72
+ /** Format label: the extension in capitals (MD, HTML, PDF, ZIP, CSV …), or 文件 / FILE when there is none. */
73
+ export function formatLabel(src, lang = "zh") {
74
74
  const ext = fileExtension(src);
75
- return ext ? ext.toUpperCase() : "文件";
75
+ return ext ? ext.toUpperCase() : lang === "en" ? "FILE" : "文件";
76
76
  }
77
77
 
78
78
  /** Icon family for the card: doc, code, archive or file. */
@@ -102,10 +102,11 @@ export const HTML_PREVIEW_SANDBOX = "allow-scripts allow-popups allow-forms";
102
102
  * on the server, no frame). HTML gets the sandbox; PDF gets none, because the
103
103
  * browser's PDF viewer does not run inside a sandboxed frame.
104
104
  */
105
- export function previewFrameProps(file) {
105
+ export function previewFrameProps(file, lang = "zh") {
106
106
  const kind = file?.preview;
107
107
  if (kind !== "html" && kind !== "pdf") return null;
108
- const props = { src: String(file.url ?? ""), title: `预览 ${file.name ?? ""}`.trim(), referrerPolicy: "no-referrer" };
108
+ const prefix = lang === "en" ? "Preview" : "预览";
109
+ const props = { src: String(file.url ?? ""), title: `${prefix} ${file.name ?? ""}`.trim(), referrerPolicy: "no-referrer" };
109
110
  return kind === "html" ? { ...props, sandbox: HTML_PREVIEW_SANDBOX } : props;
110
111
  }
111
112
 
@@ -174,7 +175,7 @@ export function readFileProps(props) {
174
175
  }
175
176
 
176
177
  /** Files of a built or authored block, whichever the fence holds. */
177
- export function blockFiles(props) {
178
+ export function blockFiles(props, lang = "zh") {
178
179
  if (props?.resolved && Array.isArray(props.resolved.files)) return props.resolved;
179
180
  const read = readFileProps(props);
180
181
  return {
@@ -185,7 +186,7 @@ export function blockFiles(props) {
185
186
  name: fileName(file.src),
186
187
  title: file.title || fileName(file.src),
187
188
  url: file.src,
188
- format: formatLabel(file.src),
189
+ format: formatLabel(file.src, lang),
189
190
  icon: fileIcon(file.src),
190
191
  remote: isRemoteSrc(file.src),
191
192
  preview: "",
@@ -1,10 +1,53 @@
1
1
  /**
2
- * The <html lang> of a page. The root layout is shared by every route and is
2
+ * The <html lang> of a page, plus 404 language resolution for missing
3
+ * /garden/en/* routes. The root layout is shared by every route and is
3
4
  * rendered once with the site language; the English handbook (/garden/en/)
4
5
  * gets lang="en" here: in the worker response and in the static export.
5
6
  */
6
7
  const EN_HANDBOOK = /(^|\/)garden\/en(\/|$)/u;
7
8
 
9
+ /**
10
+ * Strip a configured basePath (e.g. "/garden-docs-v6") from the front of a
11
+ * browser pathname. Returns a leading-slash pathname.
12
+ */
13
+ export function stripConfiguredBasePath(pathname, basePath = "") {
14
+ const raw = `/${String(pathname ?? "").replace(/^\/+/u, "")}`;
15
+ const base = String(basePath ?? "").replace(/\/+$/u, "").replace(/^\/+/u, "");
16
+ if (!base) return raw;
17
+ const prefix = `/${base}`;
18
+ if (raw === prefix) return "/";
19
+ if (raw.startsWith(`${prefix}/`)) return raw.slice(prefix.length);
20
+ return raw;
21
+ }
22
+
23
+ /**
24
+ * Normalize a browser or router pathname to an in-app route by stripping a
25
+ * configured basePath or a leading deployment prefix before /garden.
26
+ */
27
+ export function inAppPath(pathname, basePath = "") {
28
+ if (!pathname) return "/";
29
+ const stripped = stripConfiguredBasePath(pathname, basePath);
30
+ const gardenMatch = /^\/[^/]+(\/garden(?:\/.*)?)$/u.exec(stripped);
31
+ if (gardenMatch) return gardenMatch[1];
32
+ return stripped;
33
+ }
34
+
35
+ /** True when pathname (with or without a basePath) is under /garden. */
36
+ export function isHandbookPath(pathname, basePath = "") {
37
+ const p = inAppPath(pathname, basePath);
38
+ return p === "/garden" || p.startsWith("/garden/");
39
+ }
40
+
41
+ /**
42
+ * Pick the 404 page language ("en" or "zh") from a pathname after stripping
43
+ * the configured basePath. If the stripped path starts with /garden/en/ (or
44
+ * equals /garden/en), returns "en"; otherwise returns "zh".
45
+ */
46
+ export function pickNotFoundLang(pathname, basePath = "") {
47
+ const stripped = stripConfiguredBasePath(pathname, basePath);
48
+ return /^\/garden\/en(\/|$)/u.test(stripped) ? "en" : "zh";
49
+ }
50
+
8
51
  /** "en" for an English handbook path (with or without a basePath in front), else null. */
9
52
  export function handbookHtmlLang(pathname) {
10
53
  return EN_HANDBOOK.test(String(pathname ?? "").replace(/^\/+/u, "")) ? "en" : null;
@@ -14,3 +57,32 @@ export function handbookHtmlLang(pathname) {
14
57
  export function withHtmlLang(html, lang) {
15
58
  return String(html).replace(/<html\b([^>]*?)\slang="[^"]*"/u, `<html$1 lang="${lang}"`);
16
59
  }
60
+
61
+ function replaceMetaContent(html, attrName, attrValue, nextContent) {
62
+ const escapedVal = attrValue.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
63
+ const forwardRe = new RegExp(`(<meta\\b[^>]*\\b${attrName}="${escapedVal}"[^>]*\\bcontent=")[^"]*(")`, "gu");
64
+ const reverseRe = new RegExp(`(<meta\\b[^>]*\\bcontent=")[^"]*("[^>]*\\b${attrName}="${escapedVal}")`, "gu");
65
+ return html.replace(forwardRe, `$1${nextContent}$2`).replace(reverseRe, `$1${nextContent}$2`);
66
+ }
67
+
68
+ /**
69
+ * Updates <html lang="en"> and <head> metadata on a server-rendered English 404
70
+ * response so the initial HTTP response has English <title> and <meta> tags.
71
+ */
72
+ export function withEnglishNotFoundHead(html) {
73
+ let out = withHtmlLang(String(html), "en");
74
+ const titleMatch = /<title>([^<]*)<\/title>/u.exec(out);
75
+ const siteName = titleMatch ? titleMatch[1].split(" · ").at(-1)?.trim() || "Garden" : "Garden";
76
+ const enTitle = `Page not found · ${siteName}`;
77
+ const enDesc = "The address may be wrong, or the page has moved.";
78
+
79
+ out = out.replace(/<title>[^<]*<\/title>/u, `<title>${enTitle}</title>`);
80
+ out = replaceMetaContent(out, "name", "description", enDesc);
81
+ out = replaceMetaContent(out, "property", "og:title", enTitle);
82
+ out = replaceMetaContent(out, "property", "og:description", enDesc);
83
+ out = replaceMetaContent(out, "property", "og:locale", "en_US");
84
+ out = replaceMetaContent(out, "property", "og:image:alt", enTitle);
85
+ out = replaceMetaContent(out, "name", "twitter:title", enTitle);
86
+ out = replaceMetaContent(out, "name", "twitter:description", enDesc);
87
+ return out;
88
+ }
@@ -51,7 +51,7 @@ function mapInline(text, visit) {
51
51
  // Inline links read as CommonMark reads them (ui/garden/markdown-destination.mjs):
52
52
  // `[t](<https://e.com/a (1)>)` and `[t](https://e.com/Trie_(x))` are whole addresses.
53
53
  // Groups: 1 `!`, 3 or 4 the address, 6 `{card}`.
54
- const LINK = new RegExp(`${INLINE_LINK_SOURCE}(\\{card\\})?`, "giu");
54
+ const LINK = new RegExp(`${INLINE_LINK_SOURCE}(\\{card\\}|\\{source\\})?`, "giu");
55
55
  const linkUrl = (match) => match[3] ?? match[4];
56
56
  const isWebUrl = (url) => /^https?:\/\//iu.test(url);
57
57
  const AUTOLINK = /<(https?:\/\/[^\s>]+)>/giu;
@@ -62,7 +62,7 @@ export function markdownLinks(markdown) {
62
62
  const links = [];
63
63
  mapOutsideCode(markdown, (text) => {
64
64
  for (const match of text.matchAll(LINK)) {
65
- if (match[1] !== "!" && isWebUrl(linkUrl(match))) links.push({ url: linkUrl(match), card: Boolean(match[6]) });
65
+ if (match[1] !== "!" && isWebUrl(linkUrl(match))) links.push({ url: linkUrl(match), card: match[6] === CARD_MARKER });
66
66
  }
67
67
  const rest = text.replace(LINK, " ");
68
68
  for (const match of rest.matchAll(AUTOLINK)) links.push({ url: match[1], card: false });
@@ -74,14 +74,17 @@ export function markdownLinks(markdown) {
74
74
 
75
75
  /** The marker that asks for a link card: `[text](url){card}`, nothing in between. */
76
76
  export const CARD_MARKER = "{card}";
77
+ /** The marker that draws a link as an inline source chip: `[site](url){source}`. */
78
+ export const SOURCE_MARKER = "{source}";
77
79
 
78
80
  /**
79
81
  * The same Markdown without `{card}` markers, for excerpts, RSS and the search
80
82
  * index. Markers inside code stay, since there they are literal text.
81
83
  */
82
84
  export function stripCardMarkers(markdown) {
83
- if (!String(markdown ?? "").includes(CARD_MARKER)) return markdown;
84
- return mapOutsideCode(markdown, (text) => text.replace(LINK, (...match) => (match[6] && isWebUrl(linkUrl(match)) ? match[0].slice(0, -CARD_MARKER.length) : match[0])));
85
+ const raw = String(markdown ?? "");
86
+ if (!raw.includes(CARD_MARKER) && !raw.includes(SOURCE_MARKER)) return markdown;
87
+ return mapOutsideCode(markdown, (text) => text.replace(LINK, (...match) => (match[6] && isWebUrl(linkUrl(match)) ? match[0].slice(0, -match[6].length) : match[0])));
85
88
  }
86
89
 
87
90
  /** URLs the author asked to show as cards, in order, without repeats. */
@@ -1,5 +1,6 @@
1
1
  import { parse as parseYaml } from "yaml";
2
2
  import { CATALOG, knownName, legacyOf } from "./catalog.mjs";
3
+ import { relationIndex } from "./relations.mjs";
3
4
 
4
5
  const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/u;
5
6
 
@@ -91,6 +92,37 @@ function asObject(value) {
91
92
  * An empty name is an idea. Invalid YAML or an unknown name is still returned,
92
93
  * with `error` set, so the checker and the renderer share one parse.
93
94
  */
95
+ export const RELATIONS_MIN = 3;
96
+ export const RELATIONS_MAX = 6;
97
+
98
+ function relationsProblem(props) {
99
+ const items = Array.isArray(props.items) ? props.items : [];
100
+ if (items.length < RELATIONS_MIN || items.length > RELATIONS_MAX) {
101
+ return `relations 需要 ${RELATIONS_MIN} 到 ${RELATIONS_MAX} 个要素,现在是 ${items.length} 个。要素更多或有分支时改用 \`\`\`mermaid 流程图;只走一遍的步骤用 garden list。`;
102
+ }
103
+ if (props.center != null && relationIndex(props.center, items.map((item) => String(item?.title ?? ""))) < 0) {
104
+ return "center 要写放在中间的那个要素的序号(1 起)或标题。";
105
+ }
106
+ if (props.links == null) return "";
107
+ if (!Array.isArray(props.links)) return "links 必须是列表。";
108
+ const titles = items.map((item) => String(item?.title ?? ""));
109
+ const allowed = CATALOG.relations.linkKeys;
110
+ for (const [index, link] of props.links.entries()) {
111
+ const where = `links 第 ${index + 1} 条`;
112
+ if (!link || typeof link !== "object") return `${where}要写 from 和 to。`;
113
+ const unknown = Object.keys(link).filter((key) => !allowed.includes(key));
114
+ if (unknown.length) return `${where}不支持 ${unknown.join("、")}(可用:${allowed.join("、")})。`;
115
+ const from = relationIndex(link.from, titles);
116
+ const to = relationIndex(link.to, titles);
117
+ if (from < 0 || to < 0) return `${where}的 from/to 要写要素序号(1 起)或标题。`;
118
+ if (from === to) return `${where}的 from 和 to 是同一个要素。`;
119
+ if (link.style != null && !["solid", "dashed"].includes(link.style)) return `${where}的 style 只能是 solid 或 dashed。`;
120
+ if (link.direction != null && !["one", "both"].includes(link.direction)) return `${where}的 direction 只能是 one 或 both。`;
121
+ if (link.label != null && typeof link.label !== "string") return `${where}的 label 要写一小段文字。`;
122
+ }
123
+ return "";
124
+ }
125
+
94
126
  export function parseGardenBlock(info, body) {
95
127
  const header = parseGardenInfo(info);
96
128
  if (!header) return null;
@@ -148,6 +180,19 @@ export function parseGardenBlock(info, body) {
148
180
  error: { code: "garden-yaml", message: `不支持的键 ${unknown.join("、")}`, unknown, allowed: spec.keys },
149
181
  };
150
182
  }
183
+ if (header.name === "relations") {
184
+ const problem = relationsProblem(props);
185
+ if (problem) {
186
+ return {
187
+ name: header.name,
188
+ props,
189
+ children,
190
+ id: header.id ?? props.id ?? null,
191
+ legacy: Boolean(header.legacy),
192
+ error: { code: "garden-relations", message: problem },
193
+ };
194
+ }
195
+ }
151
196
  return {
152
197
  name: header.name,
153
198
  props,