hd-garden 0.7.0 → 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 (110) 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/blog/[...path]/page.tsx +9 -1
  5. package/app/columns/[column]/[...path]/page.tsx +10 -12
  6. package/app/components/ArticlePager.tsx +32 -0
  7. package/app/components/Comments.tsx +2 -1
  8. package/app/components/FeedMore.tsx +70 -0
  9. package/app/components/GardenDocsNav.tsx +25 -20
  10. package/app/components/HandbookLangRedirect.tsx +30 -0
  11. package/app/components/HandbookLangSwitch.tsx +49 -0
  12. package/app/components/HandbookView.tsx +187 -0
  13. package/app/components/MarkdownArticle.tsx +11 -5
  14. package/app/components/MarkdownImage.tsx +6 -4
  15. package/app/components/SiteChrome.tsx +11 -2
  16. package/app/components/SiteFooter.tsx +5 -3
  17. package/app/components/SiteHeader.tsx +27 -23
  18. package/app/components/TableOfContents.tsx +15 -9
  19. package/app/components/ThoughtFeed.tsx +41 -0
  20. package/app/components/UiLang.tsx +18 -0
  21. package/app/components/Webmentions.tsx +3 -2
  22. package/app/components/garden/FilePreview.tsx +13 -11
  23. package/app/components/garden/GardenBlock.tsx +3 -1
  24. package/app/components/garden/GardenFile.tsx +9 -7
  25. package/app/components/garden/blocks.tsx +34 -24
  26. package/app/components/garden/chrome.tsx +20 -13
  27. package/app/garden/[...slug]/page.tsx +10 -130
  28. package/app/garden/en/[...slug]/page.tsx +28 -0
  29. package/app/garden/en/page.tsx +16 -0
  30. package/app/garden/page.tsx +7 -74
  31. package/app/garden-modules.d.ts +3 -0
  32. package/app/layout.tsx +2 -4
  33. package/app/lib/content.ts +12 -2
  34. package/app/lib/garden-docs-meta.ts +60 -5
  35. package/app/lib/handbook-path.ts +11 -0
  36. package/app/lib/i18n.ts +222 -0
  37. package/app/lib/page-alternates.ts +2 -2
  38. package/app/not-found.tsx +5 -3
  39. package/app/site.config.ts +1 -1
  40. package/app/styles/a2.css +101 -32
  41. package/app/styles/shell.css +6 -0
  42. package/app/thoughts/page/[n]/page.tsx +44 -0
  43. package/app/thoughts/page.tsx +2 -6
  44. package/bin/garden.mjs +4 -0
  45. package/docs/handbook/ai-writing.md +99 -0
  46. package/docs/handbook/components/card.md +2 -0
  47. package/docs/handbook/components/chart.md +2 -0
  48. package/docs/handbook/components/compare.md +10 -12
  49. package/docs/handbook/components/figure.md +2 -0
  50. package/docs/handbook/components/file.md +2 -0
  51. package/docs/handbook/components/html.md +2 -0
  52. package/docs/handbook/components/interactive.md +2 -0
  53. package/docs/handbook/components/stat.md +2 -0
  54. package/docs/handbook/components/timeline.md +2 -0
  55. package/docs/handbook/components.md +1 -1
  56. package/docs/handbook/config.md +2 -2
  57. package/docs/handbook/deploy.md +1 -1
  58. package/docs/handbook/design.md +3 -3
  59. package/docs/handbook/en/ai-writing.md +99 -0
  60. package/docs/handbook/en/components/card.md +46 -0
  61. package/docs/handbook/en/components/chart.md +54 -0
  62. package/docs/handbook/en/components/compare.md +38 -0
  63. package/docs/handbook/en/components/figure.md +72 -0
  64. package/docs/handbook/en/components/file/checklist.md +17 -0
  65. package/docs/handbook/en/components/file.md +34 -0
  66. package/docs/handbook/en/components/html.md +69 -0
  67. package/docs/handbook/en/components/interactive.md +67 -0
  68. package/docs/handbook/en/components/stat.md +57 -0
  69. package/docs/handbook/en/components/timeline.md +52 -0
  70. package/docs/handbook/en/components.md +30 -0
  71. package/docs/handbook/en/config.md +125 -0
  72. package/docs/handbook/en/deploy.md +273 -0
  73. package/docs/handbook/en/design.md +81 -0
  74. package/docs/handbook/en/index.md +192 -0
  75. package/docs/handbook/en/start.md +92 -0
  76. package/docs/handbook/en/workflow.md +81 -0
  77. package/docs/handbook/en/writing.md +156 -0
  78. package/docs/handbook/index.md +98 -3
  79. package/docs/handbook/writing.md +18 -10
  80. package/package.json +1 -1
  81. package/scripts/build-content.mjs +23 -3
  82. package/scripts/handbook-facts.mjs +34 -0
  83. package/scripts/html-lang.mjs +36 -0
  84. package/scripts/static-404.mjs +25 -0
  85. package/scripts/ui-cli.mjs +14 -8
  86. package/templates/site/site.config.yaml +2 -0
  87. package/ui/core/apply.mjs +22 -4
  88. package/ui/core/cli-locale.mjs +67 -0
  89. package/ui/core/fence.mjs +18 -3
  90. package/ui/core/fix-prompt.mjs +18 -15
  91. package/ui/core/garden-fence.mjs +64 -0
  92. package/ui/core/scan.mjs +19 -3
  93. package/ui/garden/adjacent.mjs +21 -0
  94. package/ui/garden/check.mjs +6 -0
  95. package/ui/garden/html-lang.mjs +16 -0
  96. package/ui/garden/paging.mjs +30 -0
  97. package/ui/preview/dist/garden-ui.css +9 -9
  98. package/ui/preview/dist/garden-ui.esm.js +169 -169
  99. package/ui/preview/dist/garden-ui.iife.js +169 -169
  100. package/ui/react/components/chart.tsx +7 -4
  101. package/ui/react/components/display.tsx +6 -3
  102. package/ui/react/components/inputs.tsx +6 -3
  103. package/ui/react/components/layout.tsx +6 -4
  104. package/ui/react/components/media.tsx +7 -4
  105. package/ui/react/components/sandbox.tsx +3 -1
  106. package/ui/react/index.tsx +11 -7
  107. package/ui/react/lang.tsx +85 -0
  108. package/ui/react/runtime.tsx +3 -1
  109. package/ui/react/ui.css +9 -9
  110. package/worker/index.ts +8 -1
@@ -0,0 +1,222 @@
1
+ import { siteConfig } from "../site.config";
2
+
3
+ /**
4
+ * Framework chrome strings. The site language is chosen at build time with
5
+ * `locale` in site.config.yaml (zh_CN, en_US, ...); there is no runtime switch
6
+ * for the blog. The /garden/ handbook is the one bilingual area: its pages pass
7
+ * their own language explicitly.
8
+ */
9
+ export type UiLang = "zh" | "en";
10
+
11
+ export const langOf = (locale: string | undefined): UiLang => (/^en/i.test(locale ?? "") ? "en" : "zh");
12
+ export const siteLang: UiLang = langOf(siteConfig.locale);
13
+ export const htmlLang = (lang: UiLang) => (lang === "en" ? "en" : "zh-CN");
14
+
15
+ const zh = {
16
+ toc: "本篇目录",
17
+ tocShort: "目录",
18
+ tocPage: "本页目录",
19
+ open: "打开",
20
+ close: "关闭",
21
+ currentSection: ",当前章节:",
22
+ prev: "上一篇",
23
+ next: "下一篇",
24
+ pagerLabel: "上一篇和下一篇",
25
+ columnPagerLabel: "专栏里的上一篇和下一篇",
26
+ comments: "评论",
27
+ webmentions: "来自开放网络的回应",
28
+ webmentionsLead: "引用、喜欢、转发和站外回复会通过 Webmention 回到这里。",
29
+ notFoundTitle: "找不到这一页",
30
+ notFoundDeck: "地址可能写错了,或者这篇已经不在了。",
31
+ backHome: "返回首页",
32
+ builtWith: ["基于 ", " 构建"],
33
+ search: "搜索",
34
+ searchArticles: "搜索文章",
35
+ toLight: "切换到浅色模式",
36
+ toDark: "切换到深色模式",
37
+ wordCount: (n: number) => `约 ${n.toLocaleString("en-US")} 字`,
38
+ dateLocale: "zh-CN",
39
+ handbook: "Garden 使用说明",
40
+ overview: "总览",
41
+ langSwitch: "切换语言",
42
+ nextPage: "下一页",
43
+ prevPage: "上一页",
44
+ loadingMore: "正在加载…",
45
+ loadFailed: "没能加载,点这里重试",
46
+ feedEnd: "已经到底了",
47
+ pageOf: (page: number, total: number) => `第 ${page} 页,共 ${total} 页`,
48
+ skipToContent: "跳到正文",
49
+ copyCode: "复制代码",
50
+ copied: "已复制",
51
+ copiedLive: "已复制到剪贴板",
52
+ diffAdded: "(新增)",
53
+ diffRemoved: "(删除)",
54
+ codeTabs: ["代码", "对比上一版"] as string[],
55
+ codeTabsLabel: "代码",
56
+ demo: "演示",
57
+ fullscreen: "全屏",
58
+ exitFullscreen: "退出全屏",
59
+ chart: "图表",
60
+ dataTable: "查看数据表",
61
+ item: "项目",
62
+ value: "数值",
63
+ part: (n: number) => `部分 ${n}`,
64
+ pickPart: "选择一个部分",
65
+ diagram: "结构图",
66
+ pickAnswer: "先选一个答案。",
67
+ correct: "✓ 答对了",
68
+ wrong: "✗ 再想想",
69
+ bytes: "字节",
70
+ htmlAt: (src: string) => `HTML 在 ${src}。发布时由同名目录读入。`,
71
+ interactive: "交互",
72
+ ideaLabel: "想法 · 发布前编译",
73
+ unknownBlock: (name: string) => `未知 Garden 块:${name}`,
74
+ file: "文件",
75
+ openInTab: "在新标签页打开",
76
+ openNamedInTab: (name: string) => `在新标签页打开 ${name}`,
77
+ download: "下载",
78
+ downloadNamed: (name: string) => `下载 ${name}`,
79
+ downloadFile: "下载文件",
80
+ closePreview: "关闭预览",
81
+ previewFailed: "预览没能载入。",
82
+ retry: "重试",
83
+ previewLoading: "正在载入预览…",
84
+ escClose: "Esc 关闭",
85
+ preview: "预览",
86
+ previewNamed: (name: string) => `预览 ${name}`,
87
+ viewImage: "查看原图",
88
+ zoomImage: (d: string) => `放大查看:${d}`,
89
+ closeImage: "关闭图片预览",
90
+ tableScroll: "表格,可横向滚动",
91
+ tocToggle: (collapsed: boolean, text: string) => `${collapsed ? "展开" : "收起"}「${text}」的子目录`,
92
+ siteNav: "站点导航",
93
+ siteNavMobile: "站点导航(移动端)",
94
+ openMenu: "打开菜单",
95
+ closeMenu: "关闭菜单",
96
+ darkMode: "深色模式",
97
+ searchTitle: "搜索站内内容",
98
+ searchPlaceholder: "搜索标题、主题或正文…",
99
+ searchInput: "模糊搜索",
100
+ closeSearch: "关闭搜索",
101
+ searchResults: "搜索结果",
102
+ resultCount: (n: number) => `${n} 篇相关文章`,
103
+ recent: "最近内容",
104
+ searchFailed: "搜索暂时打不开,请稍后再试。",
105
+ loading: "正在载入…",
106
+ fuzzy: "支持模糊匹配",
107
+ escToClose: "关闭",
108
+ noResults: (q: string) => (q ? `没有找到和「${q}」相关的文章` : "还没有可以搜索的内容"),
109
+ navLabels: { "/blog/": "文章", "/thoughts/": "随想", "/columns/": "专栏", "/tags/": "标签", "/about/": "关于" } as Record<string, string>,
110
+ };
111
+
112
+ const en: typeof zh = {
113
+ toc: "Contents",
114
+ tocShort: "Contents",
115
+ tocPage: "On this page",
116
+ open: "Open ",
117
+ close: "Close ",
118
+ currentSection: ", current section: ",
119
+ prev: "Previous",
120
+ next: "Next",
121
+ pagerLabel: "Previous and next post",
122
+ columnPagerLabel: "Previous and next in this column",
123
+ comments: "Comments",
124
+ webmentions: "Responses from the open web",
125
+ webmentionsLead: "Mentions, likes, reposts and replies from other sites arrive here through Webmention.",
126
+ notFoundTitle: "Page not found",
127
+ notFoundDeck: "The address may be wrong, or the page has moved.",
128
+ backHome: "Back to home",
129
+ builtWith: ["Built with ", ""],
130
+ search: "Search",
131
+ searchArticles: "Search posts",
132
+ toLight: "Switch to light mode",
133
+ toDark: "Switch to dark mode",
134
+ wordCount: (n: number) => `${n.toLocaleString("en-US")} words`,
135
+ dateLocale: "en-US",
136
+ handbook: "Garden Handbook",
137
+ overview: "Overview",
138
+ langSwitch: "Switch language",
139
+ nextPage: "Next page",
140
+ prevPage: "Previous page",
141
+ loadingMore: "Loading…",
142
+ loadFailed: "Could not load. Tap to retry",
143
+ feedEnd: "That is everything",
144
+ pageOf: (page: number, total: number) => `Page ${page} of ${total}`,
145
+ skipToContent: "Skip to content",
146
+ copyCode: "Copy code",
147
+ copied: "Copied",
148
+ copiedLive: "Copied to clipboard",
149
+ diffAdded: " (added)",
150
+ diffRemoved: " (removed)",
151
+ codeTabs: ["Code", "Diff from previous"],
152
+ codeTabsLabel: "Code",
153
+ demo: "Demo",
154
+ fullscreen: "Full screen",
155
+ exitFullscreen: "Exit full screen",
156
+ chart: "Chart",
157
+ dataTable: "Show data table",
158
+ item: "Item",
159
+ value: "Value",
160
+ part: (n: number) => `Part ${n}`,
161
+ pickPart: "Choose a part",
162
+ diagram: "Diagram",
163
+ pickAnswer: "Pick an answer first.",
164
+ correct: "✓ Correct",
165
+ wrong: "✗ Try again",
166
+ bytes: "bytes",
167
+ htmlAt: (src: string) => `HTML lives in ${src}. It is read from the folder of the same name at publish time.`,
168
+ interactive: "Interactive",
169
+ ideaLabel: "Idea · compiled before publishing",
170
+ unknownBlock: (name: string) => `Unknown Garden block: ${name}`,
171
+ file: "File",
172
+ openInTab: "Open in new tab",
173
+ openNamedInTab: (name: string) => `Open ${name} in a new tab`,
174
+ download: "Download",
175
+ downloadNamed: (name: string) => `Download ${name}`,
176
+ downloadFile: "Download file",
177
+ closePreview: "Close preview",
178
+ previewFailed: "The preview could not load.",
179
+ retry: "Retry",
180
+ previewLoading: "Loading preview…",
181
+ escClose: "Esc to close",
182
+ preview: "Preview",
183
+ previewNamed: (name: string) => `Preview ${name}`,
184
+ viewImage: "View full image",
185
+ zoomImage: (d: string) => `Enlarge: ${d}`,
186
+ closeImage: "Close image preview",
187
+ tableScroll: "Table, scrolls horizontally",
188
+ tocToggle: (collapsed: boolean, text: string) => `${collapsed ? "Expand" : "Collapse"} subsections of “${text}”`,
189
+ siteNav: "Site navigation",
190
+ siteNavMobile: "Site navigation (mobile)",
191
+ openMenu: "Open menu",
192
+ closeMenu: "Close menu",
193
+ darkMode: "Dark mode",
194
+ searchTitle: "Search this site",
195
+ searchPlaceholder: "Search titles, topics or text…",
196
+ searchInput: "Fuzzy search",
197
+ closeSearch: "Close search",
198
+ searchResults: "Search results",
199
+ resultCount: (n: number) => `${n} matching ${n === 1 ? "post" : "posts"}`,
200
+ recent: "Recent",
201
+ searchFailed: "Search is unavailable right now. Please try again later.",
202
+ loading: "Loading…",
203
+ fuzzy: "Fuzzy matching",
204
+ escToClose: "to close",
205
+ noResults: (q: string) => (q ? `No posts match “${q}”` : "Nothing to search yet"),
206
+ navLabels: { "/blog/": "Posts", "/thoughts/": "Thoughts", "/columns/": "Columns", "/tags/": "Tags", "/about/": "About" },
207
+ };
208
+
209
+ export const UI_STRINGS = { zh, en } as const;
210
+ export const ui = (lang: UiLang = siteLang) => UI_STRINGS[lang];
211
+
212
+ /**
213
+ * Chrome language for a route. The handbook follows its own URL (/garden/en/
214
+ * is English, the rest of /garden/ is Chinese); every other page uses the site
215
+ * language. `pathname` is the in-app path (no basePath), as usePathname gives.
216
+ */
217
+ export function routeLang(pathname: string | null | undefined): UiLang {
218
+ const path = pathname || "/";
219
+ if (/^\/garden\/en(\/|$)/u.test(path)) return "en";
220
+ if (/^\/garden(\/|$)/u.test(path)) return "zh";
221
+ return siteLang;
222
+ }
@@ -9,6 +9,6 @@ export const FEED_ALTERNATES = { "application/rss+xml": "/rss.xml" } as const;
9
9
  * resolved against metadataBase, plus the feed. A page that sets `alternates`
10
10
  * replaces the layout's, so the feed link is repeated here.
11
11
  */
12
- export function pageAlternates(pathname: string) {
13
- return { canonical: siteHref(pathname), types: FEED_ALTERNATES };
12
+ export function pageAlternates(pathname: string, languages?: Record<string, string>) {
13
+ return { canonical: siteHref(pathname), types: FEED_ALTERNATES, ...(languages ? { languages } : {}) };
14
14
  }
package/app/not-found.tsx CHANGED
@@ -1,13 +1,15 @@
1
1
  import Link from "next/link";
2
+ import { ui } from "./lib/i18n";
2
3
 
3
4
  export default function NotFound() {
5
+ const t = ui();
4
6
  return (
5
7
  <main className="page-shell">
6
8
  <header className="sh-head">
7
- <h1>找不到这一页</h1>
8
- <p className="sh-deck">地址可能写错了,或者这篇已经不在了。</p>
9
+ <h1>{t.notFoundTitle}</h1>
10
+ <p className="sh-deck">{t.notFoundDeck}</p>
9
11
  </header>
10
- <p><Link href="/">返回首页</Link></p>
12
+ <p><Link href="/">{t.backHome}</Link></p>
11
13
  </main>
12
14
  );
13
15
  }
@@ -22,7 +22,7 @@ export type SiteConfig = {
22
22
  pages: {
23
23
  home: PageCopy;
24
24
  blog: PageCopy;
25
- thoughts: PageCopy;
25
+ thoughts: PageCopy & { pageSize?: number };
26
26
  columns: PageCopy;
27
27
  about: AboutPage;
28
28
  garden: PageCopy;
package/app/styles/a2.css CHANGED
@@ -702,7 +702,7 @@ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
702
702
  }
703
703
 
704
704
  .markdown-body .fig-card {
705
- background: var(--card);
705
+ background: transparent;
706
706
  border: 1px solid var(--rule);
707
707
  border-radius: 16px;
708
708
  padding: 12px;
@@ -886,7 +886,7 @@ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
886
886
  border: 1px solid var(--rule);
887
887
  border-radius: var(--r);
888
888
  padding: 12px 14px 14px;
889
- background: var(--card);
889
+ background: transparent;
890
890
  max-width: 100%;
891
891
  }
892
892
 
@@ -964,7 +964,7 @@ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
964
964
  .g-html {
965
965
  border: 1px solid var(--rule);
966
966
  border-radius: var(--r);
967
- background: var(--card);
967
+ background: transparent;
968
968
  }
969
969
 
970
970
  .g-card,
@@ -1111,7 +1111,7 @@ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
1111
1111
  width: 10px;
1112
1112
  height: 10px;
1113
1113
  border-radius: 50%;
1114
- background: var(--card);
1114
+ background: var(--paper);
1115
1115
  border: 2px solid var(--blue);
1116
1116
  }
1117
1117
 
@@ -1238,6 +1238,37 @@ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
1238
1238
  background: transparent;
1239
1239
  }
1240
1240
 
1241
+ /* One diff line per row; the marker column stays out of the copied text. */
1242
+ .g-diff code > span {
1243
+ display: block;
1244
+ padding: 0 12px 0 0;
1245
+ white-space: pre;
1246
+ }
1247
+
1248
+ .g-diff .d-m {
1249
+ display: inline-block;
1250
+ width: 2ch;
1251
+ color: var(--muted);
1252
+ user-select: none;
1253
+ }
1254
+
1255
+ .g-diff .d-add {
1256
+ background: var(--tint);
1257
+ }
1258
+
1259
+ .g-diff .d-add .d-m {
1260
+ color: var(--ink);
1261
+ }
1262
+
1263
+ .g-diff .d-del {
1264
+ background: var(--red-tint);
1265
+ color: var(--soft);
1266
+ }
1267
+
1268
+ .g-diff .d-del .d-m {
1269
+ color: var(--red-text);
1270
+ }
1271
+
1241
1272
  .ux-btn {
1242
1273
  display: inline-grid;
1243
1274
  place-items: center;
@@ -1246,7 +1277,7 @@ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
1246
1277
  padding: 0;
1247
1278
  border: 1px solid var(--rule);
1248
1279
  border-radius: 8px;
1249
- background: var(--card);
1280
+ background: transparent;
1250
1281
  color: var(--muted);
1251
1282
  cursor: pointer;
1252
1283
  font: 500 12px / 1 var(--sans);
@@ -1585,14 +1616,14 @@ html[data-theme="dark"] .hljs-params {
1585
1616
  .g-quiz label { display: flex; gap: 10px; align-items: center; padding: 8px 0; cursor: pointer; min-height: 44px; }
1586
1617
  .g-quiz input { accent-color: var(--blue); width: 18px; height: 18px; margin: 0; }
1587
1618
  .g-quiz-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 12px; }
1588
- .g-quiz button { font: 600 14px/1 var(--sans); padding: 11px 16px; border-radius: 8px; border: 1px solid var(--rule-strong); background: var(--card); color: var(--ink); cursor: pointer; min-height: 44px; }
1619
+ .g-quiz button { font: 600 14px/1 var(--sans); padding: 11px 16px; border-radius: 8px; border: 1px solid var(--rule-strong); background: transparent; color: var(--ink); cursor: pointer; min-height: 44px; }
1589
1620
  .g-quiz-out { margin: 0; font-size: 14px; }
1590
1621
  .g-quiz-out.ok { color: #2d6a3e; }
1591
1622
  .g-quiz-out.no { color: #b4213a; }
1592
1623
  html[data-theme="dark"] .g-quiz-out.ok { color: #8fd1a0; }
1593
1624
  html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
1594
1625
  .g-quiz-ex { margin-top: 8px; font-size: 15px; color: var(--soft); }
1595
- .g-play { border: 1px solid var(--rule); border-radius: var(--r); padding: 14px 16px; margin: 24px 0; background: var(--card); }
1626
+ .g-play { border: 1px solid var(--rule); border-radius: var(--r); padding: 14px 16px; margin: 24px 0; background: transparent; }
1596
1627
  .g-play-f { margin: 0 0 10px; font-size: 14px; color: var(--soft); }
1597
1628
  .g-range { display: grid; grid-template-columns: 110px minmax(0, 1fr) 64px; gap: 12px; align-items: center; font-size: 14px; color: var(--muted); margin: 6px 0; }
1598
1629
  .g-range input { width: 100%; accent-color: var(--blue); min-height: 32px; }
@@ -1604,20 +1635,20 @@ html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
1604
1635
  .kv-seg legend { float: left; font-size: 14px; color: var(--soft); margin-right: 10px; min-width: 3.2em; padding: 0; }
1605
1636
  .kv-seg label { position: relative; cursor: pointer; }
1606
1637
  .kv-seg input { position: absolute; opacity: 0; inset: 0; }
1607
- .kv-seg span { display: inline-flex; align-items: center; min-height: 44px; padding: 5px 10px; border: 1px solid var(--rule-strong); border-radius: 8px; font-size: 14px; background: var(--card); }
1638
+ .kv-seg span { display: inline-flex; align-items: center; min-height: 44px; padding: 5px 10px; border: 1px solid var(--rule-strong); border-radius: 8px; font-size: 14px; background: transparent; }
1608
1639
  .kv-seg input:checked + span { border-color: var(--blue); background: var(--blue-tint); color: var(--blue); }
1609
1640
  .kv-seg input:focus-visible + span { outline: 2px solid var(--blue); outline-offset: 2px; }
1610
1641
  .dx-tok { font: 500 14px ui-monospace, Menlo, monospace; fill: var(--ink); }
1611
1642
  .dx-l { font: 600 13px var(--sans); fill: var(--ink); }
1612
1643
  .dx-cap { font: 12px var(--sans); fill: var(--muted); }
1613
- .dx-k, .dx-v, .dx-q, .dx-o { fill: var(--card); stroke: var(--rule-strong); }
1644
+ .dx-k, .dx-v, .dx-q, .dx-o { fill: transparent; stroke: var(--rule-strong); }
1614
1645
  .dx-k, .dx-v { fill: var(--blue-tint); stroke: var(--blue-line); }
1615
- .dx-new { fill: var(--card); stroke: var(--blue); stroke-dasharray: 4 3; }
1646
+ .dx-new { fill: transparent; stroke: var(--blue); stroke-dasharray: 4 3; }
1616
1647
  .dx-cache { fill: none; stroke: var(--blue); stroke-width: 1.5; stroke-dasharray: 6 4; }
1617
1648
  .dx-attn line { stroke: var(--muted); stroke-width: 1; }
1618
1649
  .dx-q { stroke: var(--blue); stroke-width: 2; }
1619
1650
  .g-flow .fl-group { fill: none; stroke: var(--rule-strong); stroke-dasharray: 5 4; }
1620
- .g-flow .fl-n { fill: var(--card); stroke: var(--rule-strong); stroke-width: 1.2; }
1651
+ .g-flow .fl-n { fill: transparent; stroke: var(--rule-strong); stroke-width: 1.2; }
1621
1652
  .g-flow .fl-m { fill: var(--blue-tint); stroke: var(--blue-line); }
1622
1653
  .g-flow .fl-e line, .g-flow .fl-e1 { stroke: var(--muted); stroke-width: 1.3; fill: none; }
1623
1654
  .g-flow .fl-ah { fill: var(--muted); }
@@ -1659,7 +1690,7 @@ html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
1659
1690
  }
1660
1691
 
1661
1692
  /* ---- garden file: a download card, a group of rows, a preview drawer ---- */
1662
- .gf { margin: 24px 0; border: 1px solid var(--rule); border-radius: 12px; background: var(--card); font-family: var(--sans); }
1693
+ .gf { margin: 24px 0; border: 1px solid var(--rule); border-radius: 12px; background: transparent; font-family: var(--sans); }
1663
1694
  .gf-title { padding: 14px 16px 0; font-size: 14px; font-weight: 600; line-height: 1.4; color: var(--muted); }
1664
1695
  .gf-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
1665
1696
  .gf-row + .gf-row { border-top: 1px solid var(--rule); }
@@ -1686,7 +1717,7 @@ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
1686
1717
  .gf-dh { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px 12px 12px 20px; border-bottom: 1px solid var(--rule); }
1687
1718
  .gf-dh .gf-act { gap: 2px; }
1688
1719
  .gf-db { flex: 1; min-height: 0; overflow: auto; padding: 28px 32px 40px; font-size: 15px; line-height: 1.75; overscroll-behavior: contain; }
1689
- .gf-db-frame { padding: 0; overflow: hidden; background: var(--card); }
1720
+ .gf-db-frame { padding: 0; overflow: hidden; background: transparent; }
1690
1721
  .gf-frame { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
1691
1722
  .gf-drawer[data-preview="pdf"] .gf-frame { background: var(--paper-deep); }
1692
1723
  /* Focus catchers around the iframe: reachable by Tab, never seen. */
@@ -1833,25 +1864,10 @@ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
1833
1864
  }
1834
1865
 
1835
1866
  .gdoc-nav-group-head {
1836
- display: flex;
1837
- align-items: baseline;
1838
- justify-content: space-between;
1839
- gap: 8px;
1867
+ margin: 0 0 6px;
1840
1868
  padding: 0 10px;
1841
- margin-bottom: 6px;
1842
- }
1843
-
1844
- .gdoc-nav-group-head h2 {
1845
- margin: 0;
1846
- font: 600 12px/1.4 var(--sans);
1847
- letter-spacing: 0.02em;
1848
- color: var(--muted);
1849
- }
1850
-
1851
- .gdoc-nav-group-head span {
1852
- font: 400 11px/1.3 var(--mono);
1853
- color: var(--muted);
1854
- opacity: 0.8;
1869
+ font: 600 15px/1.45 var(--sans);
1870
+ color: var(--ink);
1855
1871
  }
1856
1872
 
1857
1873
  .gdoc-nav-list {
@@ -2008,7 +2024,7 @@ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
2008
2024
  padding: 16px 18px;
2009
2025
  border: 1px solid var(--rule);
2010
2026
  border-radius: 12px;
2011
- background: var(--card);
2027
+ background: transparent;
2012
2028
  text-decoration: none;
2013
2029
  transition: border-color 0.15s ease, background-color 0.15s ease;
2014
2030
  }
@@ -2033,6 +2049,10 @@ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
2033
2049
  }
2034
2050
 
2035
2051
  .gdoc-pager-card strong {
2052
+ display: -webkit-box;
2053
+ overflow: hidden;
2054
+ -webkit-box-orient: vertical;
2055
+ -webkit-line-clamp: 2;
2036
2056
  font-size: 15px;
2037
2057
  font-weight: 600;
2038
2058
  line-height: 1.45;
@@ -2229,3 +2249,52 @@ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
2229
2249
  }
2230
2250
  }
2231
2251
 
2252
+
2253
+ /* English handbook pages: same 740px column, tighter leading than Chinese. */
2254
+ .garden-doc-page:lang(en) .markdown-body,
2255
+ .garden-doc-page:lang(en) .gdoc-article-deck {
2256
+ line-height: 1.6;
2257
+ }
2258
+
2259
+ /* Article pager: no rule above (the Webmention block ends the section); the comments keep theirs. */
2260
+ .gdoc-pager.article-pager {
2261
+ border-top: 0;
2262
+ padding-top: 0;
2263
+ }
2264
+
2265
+ /* 随想 feed: the 下一页 link doubles as the loading trigger. */
2266
+ .feed-more {
2267
+ display: flex;
2268
+ justify-content: center;
2269
+ margin: 32px 0 8px;
2270
+ }
2271
+
2272
+ .feed-more a {
2273
+ display: inline-flex;
2274
+ align-items: center;
2275
+ min-height: 40px;
2276
+ padding: 0 18px;
2277
+ border: 1px solid var(--rule);
2278
+ border-radius: 999px;
2279
+ color: var(--soft);
2280
+ font: 400 14px/1 var(--sans);
2281
+ text-decoration: none;
2282
+ }
2283
+
2284
+ .feed-more a:hover {
2285
+ border-color: var(--rule-strong);
2286
+ color: var(--ink);
2287
+ }
2288
+
2289
+ .feed-end {
2290
+ color: var(--muted);
2291
+ font: 400 13px/1 var(--sans);
2292
+ }
2293
+
2294
+ .feed-prev {
2295
+ display: flex;
2296
+ justify-content: space-between;
2297
+ margin: 24px 0 0;
2298
+ color: var(--muted);
2299
+ font: 400 13px/1.4 var(--sans);
2300
+ }
@@ -437,3 +437,9 @@ html[data-theme="dark"] .sh-repos > li.sk::after{background:linear-gradient(90de
437
437
  @media (prefers-reduced-motion:reduce){
438
438
  .sh-col,.sh-repos > li,.sh-tagchip{transition:none}
439
439
  }
440
+ /* Handbook language switch: plain text 「中 / EN」, the current one in ink. */
441
+ .sh-lang{display:inline-flex;align-items:center;height:34px;padding:0 8px;color:var(--muted);font:400 14px/1 var(--sans);text-decoration:none;white-space:nowrap;border-radius:8px}
442
+ .sh-lang [aria-current]{color:var(--ink);font-weight:600}
443
+ a.sh-lang:hover{color:var(--ink)}
444
+ a.sh-lang:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
445
+ .sh-lang.is-disabled{opacity:.55;cursor:default}
@@ -0,0 +1,44 @@
1
+ import { notFound } from "next/navigation";
2
+ import { PageIntro } from "../../../components/PageIntro";
3
+ import { ThoughtFeed, thoughtsPageCount, thoughtsPageSize } from "../../../components/ThoughtFeed";
4
+ import { getPublishedThoughts } from "../../../lib/public-thoughts";
5
+ import { ui } from "../../../lib/i18n";
6
+ import { pageAlternates } from "../../../lib/page-alternates";
7
+ import { siteConfig } from "../../../site.config";
8
+ import { extraPages, pagePath } from "../../../../ui/garden/paging.mjs";
9
+ import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
10
+
11
+ export function generateStaticParams() {
12
+ return staticParams(
13
+ extraPages(getPublishedThoughts().length, thoughtsPageSize()).map((page: number) => ({ n: String(page) })),
14
+ { n: STATIC_PLACEHOLDER },
15
+ );
16
+ }
17
+
18
+ const pageNumber = (value: string) => (/^\d+$/u.test(value) ? Number(value) : NaN);
19
+
20
+ export async function generateMetadata({ params }: { params: Promise<{ n: string }> }) {
21
+ const { n } = await params;
22
+ const page = pageNumber(n);
23
+ if (!(page >= 2 && page <= thoughtsPageCount())) return {};
24
+ return {
25
+ title: `${siteConfig.pages.thoughts.title} · ${ui().pageOf(page, thoughtsPageCount())}`,
26
+ description: siteConfig.pages.thoughts.subtitle || siteConfig.description,
27
+ alternates: pageAlternates(pagePath("/thoughts", page)),
28
+ };
29
+ }
30
+
31
+ export default async function ThoughtsPage({ params }: { params: Promise<{ n: string }> }) {
32
+ const { n } = await params;
33
+ const page = pageNumber(n);
34
+ if (!(page >= 2 && page <= thoughtsPageCount())) {
35
+ if (isStaticPlaceholder(n)) return null;
36
+ notFound();
37
+ }
38
+ return (
39
+ <main className="page-shell thoughts-page">
40
+ <PageIntro eyebrow="THOUGHTS / 随想" title={siteConfig.pages.thoughts.title} subtitle={siteConfig.pages.thoughts.subtitle} />
41
+ <ThoughtFeed page={page} />
42
+ </main>
43
+ );
44
+ }
@@ -1,5 +1,5 @@
1
1
  import { getPublishedThoughts } from "../lib/public-thoughts";
2
- import { ThoughtCard } from "../components/ThoughtCard";
2
+ import { ThoughtFeed } from "../components/ThoughtFeed";
3
3
  import { ContentEmptyState } from "../components/ContentEmptyState";
4
4
  import { PageIntro } from "../components/PageIntro";
5
5
  import { siteConfig, siteDocumentTitle } from "../site.config";
@@ -22,11 +22,7 @@ export default function ThoughtsPage() {
22
22
  subtitle={siteConfig.pages.thoughts.subtitle}
23
23
  />
24
24
  {thoughts.length > 0 ? (
25
- <div className="thought-stream">
26
- {thoughts.map((thought) => (
27
- <ThoughtCard key={thought.slug} thought={thought} />
28
- ))}
29
- </div>
25
+ <ThoughtFeed page={1} />
30
26
  ) : (
31
27
  <ContentEmptyState kind="thoughts" />
32
28
  )}
package/bin/garden.mjs CHANGED
@@ -398,6 +398,10 @@ switch (command) {
398
398
  // What the export wrote for empty dynamic routes (ui/garden/static-params.mjs).
399
399
  runNode("scripts/static-placeholders.mjs");
400
400
  runNode("scripts/redirects.mjs");
401
+ // <html lang="en"> on the English handbook pages.
402
+ runNode("scripts/html-lang.mjs");
403
+ // 404.html also under basePath when the host serves that folder alone.
404
+ runNode("scripts/static-404.mjs");
401
405
  }
402
406
  break;
403
407
  case "links":