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
@@ -14,9 +14,11 @@ import type Fuse from "fuse.js";
14
14
  import { MagnifyingGlass, X } from "@phosphor-icons/react";
15
15
  import { brand, searchFull, searchIndexUrl } from "@garden/site-config";
16
16
  import { createSearch } from "../../ui/garden/search-match.mjs";
17
- import { cropMatchedText, highlightParts, noResultsText } from "../../ui/garden/search-preview.mjs";
17
+ import { cropMatchedText, highlightParts } from "../../ui/garden/search-preview.mjs";
18
18
  import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
19
19
  import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
20
+ import { isHandbookPath, routeLang, ui } from "../lib/i18n";
21
+ import { HandbookLangSwitch } from "./HandbookLangSwitch";
20
22
  import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
21
23
  import { siteConfig } from "../site.config";
22
24
  import { siteHref } from "../lib/site-href";
@@ -87,7 +89,7 @@ function loadSearchEngine() {
87
89
  return engineLoad;
88
90
  }
89
91
 
90
- const nav = SITE_NAV.map((item) => ({ ...item, href: siteHref(item.href) }));
92
+ const nav = SITE_NAV.map((item) => ({ ...item, key: item.href, href: siteHref(item.href) }));
91
93
 
92
94
  function rangesFor(
93
95
  matches: readonly SearchMatch[] | undefined,
@@ -140,6 +142,7 @@ function searchPreview(
140
142
 
141
143
  export function SiteHeader() {
142
144
  const pathname = usePathname() || "/";
145
+ const t = ui(routeLang(pathname));
143
146
  const [menuOpen, setMenuOpen] = useState(false);
144
147
  const [searchOpen, setSearchOpen] = useState(false);
145
148
  const [query, setQuery] = useState("");
@@ -189,7 +192,12 @@ export function SiteHeader() {
189
192
  useEffect(() => {
190
193
  const system = window.matchMedia("(prefers-color-scheme: dark)");
191
194
  const apply = () => {
192
- const stored = window.localStorage.getItem("theme");
195
+ let stored: string | null = null;
196
+ try {
197
+ stored = window.localStorage.getItem("theme");
198
+ } catch {
199
+ // Storage blocked (cookies off, opaque origin): follow the system.
200
+ }
193
201
  const useDark = resolveDark(stored, system.matches);
194
202
  document.documentElement.dataset.theme = useDark ? "dark" : "light";
195
203
  setDark(useDark);
@@ -295,7 +303,11 @@ export function SiteHeader() {
295
303
  const next = !dark;
296
304
  setDark(next);
297
305
  document.documentElement.dataset.theme = next ? "dark" : "light";
298
- window.localStorage.setItem("theme", next ? "dark" : "light");
306
+ try {
307
+ window.localStorage.setItem("theme", next ? "dark" : "light");
308
+ } catch {
309
+ // Storage blocked: the choice lasts for this page only.
310
+ }
299
311
  };
300
312
 
301
313
  const current = (href: string) => isCurrentNav(pathname, href);
@@ -313,29 +325,30 @@ export function SiteHeader() {
313
325
  {brand.logo ? <span className="sh-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: brand.logo }} /> : null}
314
326
  {siteConfig.name}
315
327
  </Link>
316
- <nav className="sh-nav" aria-label="站点导航">
328
+ <nav className="sh-nav" aria-label={t.siteNav}>
317
329
  {nav.map((item) => (
318
330
  <Link key={item.href} href={item.href} aria-current={current(item.href) ? "page" : undefined}>
319
- {item.label}
331
+ {t.navLabels[item.key] ?? item.label}
320
332
  </Link>
321
333
  ))}
322
334
  </nav>
323
335
  <div className="sh-tools">
324
- <button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label="搜索文章" aria-expanded={searchOpen} aria-controls="site-search-dialog">
336
+ <button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label={t.searchArticles} aria-expanded={searchOpen} aria-controls="site-search-dialog">
325
337
  <svg viewBox="0 0 20 20" aria-hidden="true">
326
338
  <circle cx="9" cy="9" r="5.8" />
327
339
  <path d="M13.3 13.3 17 17" />
328
340
  </svg>
329
- <span className="lbl">搜索</span>
341
+ <span className="lbl">{t.search}</span>
330
342
  <kbd><span className="kbd-mod kbd-mac">⌘</span><span className="kbd-mod kbd-other">Ctrl</span>{" "}K</kbd>
331
343
  </button>
332
- <button className="sh-icon sh-search-m" type="button" onClick={openSearch} onPointerDown={prefetchSearch} aria-label="搜索文章">
344
+ <button className="sh-icon sh-search-m" type="button" onClick={openSearch} onPointerDown={prefetchSearch} aria-label={t.searchArticles}>
333
345
  <svg viewBox="0 0 20 20" aria-hidden="true">
334
346
  <circle cx="9" cy="9" r="5.8" />
335
347
  <path d="M13.3 13.3 17 17" />
336
348
  </svg>
337
349
  </button>
338
- <button className="sh-icon sh-theme" type="button" onClick={toggleTheme} aria-label={dark ? "切换到浅色模式" : "切换到深色模式"}>
350
+ {isHandbookPath(pathname) ? <HandbookLangSwitch /> : null}
351
+ <button className="sh-icon sh-theme" type="button" onClick={toggleTheme} aria-label={dark ? t.toLight : t.toDark}>
339
352
  <svg className="i-moon" viewBox="0 0 20 20" aria-hidden="true">
340
353
  <path d="M16.5 12.2A6.8 6.8 0 0 1 7.8 3.5a6.8 6.8 0 1 0 8.7 8.7Z" />
341
354
  </svg>
@@ -344,27 +357,27 @@ export function SiteHeader() {
344
357
  <path d="M10 1.8v2M10 16.2v2M1.8 10h2M16.2 10h2M4.2 4.2l1.4 1.4M14.4 14.4l1.4 1.4M4.2 15.8l1.4-1.4M14.4 5.6l1.4-1.4" />
345
358
  </svg>
346
359
  </button>
347
- <button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ? "关闭菜单" : "打开菜单"} onClick={() => setMenuOpen((open) => !open)}>
360
+ <button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ? t.closeMenu : t.openMenu} onClick={() => setMenuOpen((open) => !open)}>
348
361
  {menuOpen ? <X size={18} /> : <span aria-hidden="true">☰</span>}
349
362
  </button>
350
363
  {/* Without JavaScript the button above cannot open anything: these two links take its place
351
364
  and open or close the menu through :target on the fixed anchors above (shell.css). */}
352
- <a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu-on" aria-label="打开菜单">
365
+ <a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu-on" aria-label={t.openMenu}>
353
366
  <span aria-hidden="true">☰</span>
354
367
  </a>
355
- <a className="sh-icon sh-menu-nojs-close" href="#sh-menu-off" aria-label="关闭菜单">
368
+ <a className="sh-icon sh-menu-nojs-close" href="#sh-menu-off" aria-label={t.closeMenu}>
356
369
  <X size={18} />
357
370
  </a>
358
371
  </div>
359
372
  </div>
360
- <nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label="站点导航(移动端)">
373
+ <nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label={t.siteNavMobile}>
361
374
  {nav.map((item) => (
362
375
  <Link key={item.href} href={item.href} aria-current={current(item.href) ? "page" : undefined} onClick={() => setMenuOpen(false)}>
363
- {item.label}
376
+ {t.navLabels[item.key] ?? item.label}
364
377
  </Link>
365
378
  ))}
366
379
  <button className="sh-menu-theme" {...themeSwitchProps(dark, toggleTheme)}>
367
- <span>深色模式</span>
380
+ <span>{t.darkMode}</span>
368
381
  <span className="sh-switch" aria-hidden="true" />
369
382
  </button>
370
383
  </nav>
@@ -388,7 +401,7 @@ export function SiteHeader() {
388
401
  onKeyDown={handleSearchKeyDown}
389
402
  >
390
403
  <h2 className="sr-only" id="site-search-title">
391
- 搜索站内内容
404
+ {t.searchTitle}
392
405
  </h2>
393
406
  <div className="search-input-wrap">
394
407
  <MagnifyingGlass size={20} aria-hidden="true" />
@@ -400,8 +413,8 @@ export function SiteHeader() {
400
413
  setActiveResult(-1);
401
414
  resultRefs.current = [];
402
415
  }}
403
- placeholder="搜索标题、主题或正文…"
404
- aria-label="模糊搜索"
416
+ placeholder={t.searchPlaceholder}
417
+ aria-label={t.searchInput}
405
418
  role="combobox"
406
419
  aria-expanded="true"
407
420
  aria-autocomplete="list"
@@ -410,7 +423,7 @@ export function SiteHeader() {
410
423
  activeResult >= 0 ? `site-search-result-${activeResult}` : undefined
411
424
  }
412
425
  />
413
- <button onClick={closeSearch} aria-label="关闭搜索">
426
+ <button onClick={closeSearch} aria-label={t.closeSearch}>
414
427
  <X size={19} />
415
428
  </button>
416
429
  </div>
@@ -418,11 +431,11 @@ export function SiteHeader() {
418
431
  className="search-results"
419
432
  id="site-search-results"
420
433
  role="listbox"
421
- aria-label="搜索结果"
434
+ aria-label={t.searchResults}
422
435
  >
423
436
  {query && engine && resultTotal === 0 ? null : (
424
437
  <p className="search-label">
425
- {query ? `${resultTotal} 篇相关文章` : "最近内容"}
438
+ {query ? t.resultCount(resultTotal) : t.recent}
426
439
  </p>
427
440
  )}
428
441
  {results.map((result, index) => {
@@ -464,13 +477,13 @@ export function SiteHeader() {
464
477
  {results.length === 0 && (
465
478
  <p className="empty-state">
466
479
  {/* The query is a React text child, so it is escaped. */}
467
- {engine ? noResultsText(query) : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
480
+ {engine ? t.noResults(query.trim()) : engineFailed ? t.searchFailed : t.loading}
468
481
  </p>
469
482
  )}
470
483
  </div>
471
484
  <footer className="search-footer">
472
- <span>支持模糊匹配</span>
473
- <span><kbd>ESC</kbd> 关闭</span>
485
+ <span>{t.fuzzy}</span>
486
+ <span><kbd>ESC</kbd> {t.escToClose}</span>
474
487
  </footer>
475
488
  </section>
476
489
  </div>
@@ -10,6 +10,7 @@ import {
10
10
  type ReactNode,
11
11
  } from "react";
12
12
  import { List, X } from "@phosphor-icons/react";
13
+ import { ui, type UiLang } from "../lib/i18n";
13
14
 
14
15
  type Heading = { depth: number; text: string; id: string; no?: string };
15
16
 
@@ -115,11 +116,16 @@ const scrollToHeading = (id: string) => {
115
116
 
116
117
  export function TableOfContents({
117
118
  headings,
118
- label = "本篇目录",
119
+ label,
120
+ lang,
119
121
  }: {
120
122
  headings: Heading[];
121
123
  label?: string;
124
+ /** Defaults to the site language; the bilingual handbook passes its own. */
125
+ lang?: UiLang;
122
126
  }) {
127
+ const t = ui(lang);
128
+ label ??= t.toc;
123
129
  const [activeId, setActiveId] = useState(headings[0]?.id);
124
130
  const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
125
131
  () => new Set(),
@@ -368,7 +374,7 @@ export function TableOfContents({
368
374
  type="button"
369
375
  aria-controls={childListId}
370
376
  aria-expanded={!collapsed}
371
- aria-label={`${collapsed ? "展开" : "收起"}「${node.text}」的子目录`}
377
+ aria-label={t.tocToggle(collapsed, node.text)}
372
378
  onClick={() => toggleSection(node.id)}
373
379
  >
374
380
  <span aria-hidden="true">{collapsed ? "+" : "−"}</span>
@@ -396,25 +402,25 @@ export function TableOfContents({
396
402
  type="button"
397
403
  aria-controls={panelId}
398
404
  aria-expanded={mobileOpen}
399
- aria-label={`打开${label}${activeHeading ? `,当前章节:${activeHeading.text}` : ""}`}
405
+ aria-label={`${t.open}${label}${activeHeading ? `${t.currentSection}${activeHeading.text}` : ""}`}
400
406
  tabIndex={mobileOpen ? -1 : 0}
401
407
  data-mobile-open={mobileOpen}
402
408
  onClick={() => setMobileOpen(true)}
403
409
  >
404
410
  <List size={17} weight="bold" aria-hidden="true" />
405
- <span>目录</span>
411
+ <span>{t.tocShort}</span>
406
412
  </button>
407
413
  {/* Without JavaScript the button above does nothing. This link opens the
408
414
  drawer through :target instead; CSS shows it only when scripting is
409
415
  off, so with JavaScript nothing here is visible. */}
410
- <a className="toc-nojs-trigger" id={`${panelId}-open`} href={`#${panelId}`} aria-label={`打开${label}`}>
416
+ <a className="toc-nojs-trigger" id={`${panelId}-open`} href={`#${panelId}`} aria-label={`${t.open}${label}`}>
411
417
  <List size={17} weight="bold" aria-hidden="true" />
412
- <span>目录</span>
418
+ <span>{t.tocShort}</span>
413
419
  </a>
414
420
  <button
415
421
  className="toc-mobile-backdrop"
416
422
  type="button"
417
- aria-label={`关闭${label}`}
423
+ aria-label={`${t.close}${label}`}
418
424
  tabIndex={mobileOpen ? 0 : -1}
419
425
  data-mobile-open={mobileOpen}
420
426
  onClick={() => closeMobileToc(true)}
@@ -431,12 +437,12 @@ export function TableOfContents({
431
437
  ref={mobileCloseRef}
432
438
  className="toc-mobile-close"
433
439
  type="button"
434
- aria-label={`关闭${label}`}
440
+ aria-label={`${t.close}${label}`}
435
441
  onClick={() => closeMobileToc(true)}
436
442
  >
437
443
  <X size={18} aria-hidden="true" />
438
444
  </button>
439
- <a className="toc-nojs-close" href={`#${panelId}-open`} aria-label={`关闭${label}`}>
445
+ <a className="toc-nojs-close" href={`#${panelId}-open`} aria-label={`${t.close}${label}`}>
440
446
  <X size={18} aria-hidden="true" />
441
447
  </a>
442
448
  </div>
@@ -15,6 +15,7 @@ import {
15
15
  import { githubUrl, siteConfig } from "../site.config";
16
16
  import { MarkdownArticle } from "./MarkdownArticle";
17
17
  import { siteHref } from "../lib/site-href";
18
+ import { siteLang, ui } from "../lib/i18n";
18
19
 
19
20
  const icons = {
20
21
  image: ImageSquare,
@@ -66,11 +67,12 @@ export function ThoughtCard({
66
67
  const isMixed = media.length > 1;
67
68
  const Icon = isMixed ? SquaresFour : icons[thought.mediaType ?? "text"];
68
69
  const mediaGroups = groupMedia(media);
70
+ const t = ui();
69
71
 
70
72
  return (
71
73
  <article id={thought.slug} className="thought-card h-entry">
72
74
  <Link className="u-url sr-only" href={siteHref(`/thoughts/${thought.slug}`)}>
73
- {thought.title} 永久链接
75
+ {siteLang === "en" ? t.permalinkLabel(thought.title) : <>{thought.title} 永久链接</>}
74
76
  </Link>
75
77
  <a
76
78
  className="p-author h-card sr-only"
@@ -83,16 +85,16 @@ export function ThoughtCard({
83
85
  <span className="thought-kind">
84
86
  <Icon size={16} />
85
87
  {isMixed
86
- ? `混合记录 · ${media.length} 个附件`
88
+ ? t.thoughtMixedLabel(media.length)
87
89
  : thought.mediaType === "image"
88
- ? "一张照片"
90
+ ? t.thoughtImageLabel
89
91
  : thought.mediaType === "audio"
90
- ? "一段声音"
92
+ ? t.thoughtAudioLabel
91
93
  : thought.mediaType === "video"
92
- ? "一段影像"
94
+ ? t.thoughtVideoLabel
93
95
  : thought.mediaType === "link"
94
- ? "一个链接"
95
- : "随想"}
96
+ ? t.thoughtLinkLabel
97
+ : t.thoughtTextLabel}
96
98
  </span>
97
99
  <time className="dt-published" dateTime={thought.date}>
98
100
  {formatDate(thought.date, true)}
@@ -112,7 +114,7 @@ export function ThoughtCard({
112
114
  <div
113
115
  className={`thought-gallery thought-gallery-${Math.min(group.items.length, 3)}`}
114
116
  key={`images-${groupIndex}`}
115
- aria-label={`${group.items.length} 张照片`}
117
+ aria-label={t.thoughtGalleryLabel(group.items.length)}
116
118
  >
117
119
  {group.items.map((item, index) => (
118
120
  // eslint-disable-next-line @next/next/no-img-element
@@ -120,7 +122,7 @@ export function ThoughtCard({
120
122
  key={`${item.src}-${index}`}
121
123
  className="thought-image u-photo"
122
124
  src={item.src}
123
- alt={item.alt ?? `${thought.title} · 照片 ${index + 1}`}
125
+ alt={item.alt ?? t.thoughtPhotoAlt(thought.title, index + 1)}
124
126
  loading="lazy"
125
127
  />
126
128
  ))}
@@ -132,14 +134,14 @@ export function ThoughtCard({
132
134
  <div className="audio-embed" key={`${group.item.src}-${groupIndex}`}>
133
135
  <MusicNotes size={28} />
134
136
  <div>
135
- <strong>{group.item.title ?? `当前播放 · ${thought.title}`}</strong>
137
+ <strong>{group.item.title ?? t.thoughtNowPlaying(thought.title)}</strong>
136
138
  <audio
137
139
  className="u-audio"
138
140
  controls
139
141
  preload="none"
140
142
  src={group.item.src}
141
143
  >
142
- 你的浏览器不支持音频播放。
144
+ {t.audioFallback}
143
145
  </audio>
144
146
  </div>
145
147
  </div>
@@ -194,14 +196,14 @@ export function ThoughtCard({
194
196
  <div className="audio-embed">
195
197
  <MusicNotes size={28} />
196
198
  <div>
197
- <strong>当前播放 · {thought.title}</strong>
199
+ <strong>{t.thoughtNowPlaying(thought.title)}</strong>
198
200
  <audio
199
201
  className="u-audio"
200
202
  controls
201
203
  preload="none"
202
204
  src={thought.media}
203
205
  >
204
- 你的浏览器不支持音频播放。
206
+ {t.audioFallback}
205
207
  </audio>
206
208
  </div>
207
209
  </div>
@@ -234,7 +236,7 @@ export function ThoughtCard({
234
236
 
235
237
  {showPermalink && (
236
238
  <Link className="thought-permalink" href={siteHref(`/thoughts/${thought.slug}`)}>
237
- 永久链接
239
+ {t.thoughtPermalinkShort}
238
240
  <ArrowUpRight size={15} />
239
241
  </Link>
240
242
  )}
@@ -0,0 +1,41 @@
1
+ import Link from "next/link";
2
+ import { ThoughtCard } from "./ThoughtCard";
3
+ import { FeedMore } from "./FeedMore";
4
+ import { getPublishedThoughts } from "../lib/public-thoughts";
5
+ import { ui } from "../lib/i18n";
6
+ import { siteHref } from "../lib/site-href";
7
+ import { siteConfig } from "../site.config";
8
+ import { pageCount, pageItems, pagePath, pageSizeOf } from "../../ui/garden/paging.mjs";
9
+
10
+ export const thoughtsPageSize = () => pageSizeOf(siteConfig.pages.thoughts.pageSize);
11
+ export const thoughtsPageCount = () => pageCount(getPublishedThoughts().length, thoughtsPageSize());
12
+ export const thoughtsPageHref = (page: number) => siteHref(pagePath("/thoughts", page));
13
+
14
+ /** One page of 随想 and the link (or automatic loading) to the next. */
15
+ export function ThoughtFeed({ page }: { page: number }) {
16
+ const t = ui();
17
+ const all = getPublishedThoughts();
18
+ const total = thoughtsPageCount();
19
+ const items = pageItems(all, page, thoughtsPageSize());
20
+ const next = page < total ? thoughtsPageHref(page + 1) : null;
21
+ return (
22
+ <>
23
+ <div className="thought-stream" data-feed-page={page}>
24
+ {items.map((thought) => (
25
+ <ThoughtCard key={thought.slug} thought={thought} />
26
+ ))}
27
+ </div>
28
+ {page > 1 ? (
29
+ <p className="feed-prev">
30
+ <Link href={thoughtsPageHref(page - 1)} rel="prev">{t.prevPage}</Link>
31
+ <span>{t.pageOf(page, total)}</span>
32
+ </p>
33
+ ) : null}
34
+ <FeedMore
35
+ next={next}
36
+ streamSelector=".thought-stream"
37
+ labels={{ nextPage: t.nextPage, loadingMore: t.loadingMore, loadFailed: t.loadFailed, feedEnd: t.feedEnd }}
38
+ />
39
+ </>
40
+ );
41
+ }
@@ -1,9 +1,10 @@
1
1
  import { readPayload } from "../../ui/core/compile.mjs";
2
2
  import { IntentCard, UiErrorCard, UiFigure, type Diagnostic, type UiPayload } from "../../ui/react";
3
+ import { ui, type UiLang } from "../lib/i18n";
3
4
 
4
- export function UiBlock({ source }: { source: string }) {
5
+ export function UiBlock({ source, lang }: { source: string; lang?: UiLang }) {
5
6
  const payload = readPayload(source) as UiPayload | null;
6
- if (!payload) return <p className="external-embed-error">这块交互的数据损坏了,请重新运行 garden sync。</p>;
7
+ if (!payload) return <p className="external-embed-error">{ui(lang).uiBlockCorrupt}</p>;
7
8
  return <UiFigure payload={payload} />;
8
9
  }
9
10
 
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+ import { siteLang, ui, type UiLang } from "../lib/i18n";
5
+
6
+ /**
7
+ * The language of the framework chrome inside a page body (copy buttons, code
8
+ * tabs, file cards...). It is the site language everywhere except the /garden/
9
+ * handbook, whose pages set it from their own route (/garden/en/ is English).
10
+ */
11
+ const UiLangContext = createContext<UiLang>(siteLang);
12
+
13
+ export function UiLangProvider({ lang, children }: { lang?: UiLang; children: ReactNode }) {
14
+ return <UiLangContext.Provider value={lang ?? siteLang}>{children}</UiLangContext.Provider>;
15
+ }
16
+
17
+ export const useUiLang = () => useContext(UiLangContext);
18
+ export const useUi = () => ui(useContext(UiLangContext));
@@ -8,6 +8,7 @@ import {
8
8
  Quotes,
9
9
  Repeat,
10
10
  } from "@phosphor-icons/react";
11
+ import { siteLang, ui } from "../lib/i18n";
11
12
 
12
13
  type WebmentionEntry = {
13
14
  author?: {
@@ -93,34 +94,34 @@ export function Webmentions({
93
94
  <div className="webmention-title">
94
95
  <LinkSimple size={21} />
95
96
  <div>
96
- <h2 id="webmentions-heading">来自开放网络的回应</h2>
97
- <p>引用、喜欢、转发和站外回复会通过 Webmention 回到这里。</p>
97
+ <h2 id="webmentions-heading">{ui().webmentions}</h2>
98
+ <p>{ui().webmentionsLead}</p>
98
99
  </div>
99
100
  </div>
100
101
 
101
102
  {!endpoint && (
102
103
  <div className="integration-notice">
103
104
  <Quotes size={20} weight="fill" />
104
- <p>接收与展示代码已经就绪,注册正式域名后即可开始收集回应。</p>
105
+ <p>{ui().webmentionIdleNote}</p>
105
106
  </div>
106
107
  )}
107
108
 
108
109
  {endpoint && loading && (
109
110
  <p className="integration-status" role="status">
110
- 正在读取开放网络回应…
111
+ {ui().webmentionLoading}
111
112
  </p>
112
113
  )}
113
114
 
114
115
  {endpoint && failed && (
115
116
  <p className="integration-status" role="status">
116
- 暂时无法读取回应,稍后会自动恢复。
117
+ {ui().webmentionUnavailable}
117
118
  </p>
118
119
  )}
119
120
 
120
121
  {endpoint && !loading && !failed && entries.length === 0 && (
121
122
  <div className="integration-notice">
122
123
  <Quotes size={20} weight="fill" />
123
- <p>还没有站外回应。任何链接到这篇文章的网站都可以向它发送 Webmention。</p>
124
+ <p>{ui().webmentionEmpty}</p>
124
125
  </div>
125
126
  )}
126
127
 
@@ -129,15 +130,15 @@ export function Webmentions({
129
130
  <span>
130
131
  <Heart size={16} weight="fill" />
131
132
  <Repeat size={16} />
132
- {reactions.length} 个喜欢、转发或收藏
133
+ {ui().webmentionReactionCount(reactions.length)}
133
134
  </span>
134
- <div className="webmention-facepile" aria-label="回应者">
135
+ <div className="webmention-facepile" aria-label={ui().webmentionRespondersLabel}>
135
136
  {reactions.slice(0, 16).map((entry, index) => (
136
137
  <a
137
138
  href={entry.url}
138
139
  target="_blank"
139
140
  rel="noopener noreferrer"
140
- aria-label={entry.author?.name ?? "查看站外回应"}
141
+ aria-label={entry.author?.name ?? ui().webmentionViewResponse}
141
142
  key={`${entry.url}-${index}`}
142
143
  >
143
144
  {entry.author?.photo ? (
@@ -164,10 +165,10 @@ export function Webmentions({
164
165
  return (
165
166
  <article key={`${entry.url}-${index}`}>
166
167
  <header>
167
- <strong>{entry.author?.name ?? "开放网络访客"}</strong>
168
+ <strong>{entry.author?.name ?? ui().webmentionAuthorFallback}</strong>
168
169
  {received && (
169
170
  <time dateTime={received}>
170
- {new Intl.DateTimeFormat("zh-CN", {
171
+ {new Intl.DateTimeFormat(siteLang === "en" ? "en-US" : "zh-CN", {
171
172
  year: "numeric",
172
173
  month: "2-digit",
173
174
  day: "2-digit",
@@ -181,7 +182,7 @@ export function Webmentions({
181
182
  target="_blank"
182
183
  rel="noopener noreferrer"
183
184
  >
184
- 查看原文
185
+ {ui().webmentionViewOriginal}
185
186
  <ArrowSquareOut size={14} />
186
187
  </a>
187
188
  </article>
@@ -3,6 +3,7 @@
3
3
  import { ArrowUpRight, Browser, CaretDown, FilePdf } from "@phosphor-icons/react";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { parse } from "yaml";
6
+ import { ui, type UiLang } from "../lib/i18n";
6
7
 
7
8
  type WidgetKind = "chart" | "html" | "pdf";
8
9
 
@@ -79,7 +80,8 @@ function DocumentThumb({ kind }: { kind: "html" | "pdf" }) {
79
80
  );
80
81
  }
81
82
 
82
- export function WidgetChart({ source }: { source: string }) {
83
+ export function WidgetChart({ source, lang }: { source: string; lang?: UiLang }) {
84
+ const s = ui(lang);
83
85
  const data = readWidgetData(source);
84
86
  const src = data?.src;
85
87
  const kind = data?.kind;
@@ -132,7 +134,7 @@ export function WidgetChart({ source }: { source: string }) {
132
134
  if (!data) {
133
135
  return (
134
136
  <p className="external-embed-error">
135
- 无法解析这个嵌入块。请在与 Markdown 同名的目录里放一份 .html 或 .pdf,并在围栏中写 src 与 caption。
137
+ {s.widgetParseError}
136
138
  </p>
137
139
  );
138
140
  }
@@ -156,7 +158,7 @@ export function WidgetChart({ source }: { source: string }) {
156
158
  <figcaption>
157
159
  <span>{data.caption}</span>
158
160
  <a href={data.src} target="_blank" rel="noopener noreferrer">
159
- 打开页面
161
+ {s.widgetOpenPage}
160
162
  </a>
161
163
  </figcaption>
162
164
  <noscript>
@@ -167,8 +169,8 @@ export function WidgetChart({ source }: { source: string }) {
167
169
  }
168
170
 
169
171
  const Icon = data.kind === "pdf" ? FilePdf : Browser;
170
- const kindLabel = data.kind === "pdf" ? "PDF" : "页面";
171
- const openLabel = data.kind === "pdf" ? "打开 PDF" : "打开页面";
172
+ const kindLabel = data.kind === "pdf" ? "PDF" : s.widgetPageKind;
173
+ const openLabel = data.kind === "pdf" ? s.widgetOpenPdf : s.widgetOpenPage;
172
174
 
173
175
  return (
174
176
  <figure
@@ -197,7 +199,7 @@ export function WidgetChart({ source }: { source: string }) {
197
199
  <strong>{data.caption}</strong>
198
200
  </span>
199
201
  <span className="garden-doc-action">
200
- {expanded ? "收起" : "展开"}
202
+ {expanded ? s.widgetCollapse : s.widgetExpand}
201
203
  <CaretDown size={14} className="garden-doc-caret" />
202
204
  </span>
203
205
  </button>