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
@@ -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>
@@ -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
+ }
@@ -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 { ui } from "../lib/i18n";
11
12
 
12
13
  type WebmentionEntry = {
13
14
  author?: {
@@ -93,8 +94,8 @@ 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
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useId, useRef, useState, type FocusEvent, type MouseEvent } from "react";
4
4
  import { createPortal } from "react-dom";
5
+ import { ui, type UiLang } from "../../lib/i18n";
5
6
  import { previewBody, previewFrameProps } from "../../../ui/garden/file-block.mjs";
6
7
  import { ActionGlyph, FileGlyph } from "./file-icons";
7
8
 
@@ -25,7 +26,8 @@ type Loaded = { state: "idle" | "loading" | "error" } | { state: "ready"; html:
25
26
  * The control is a link: without JavaScript it opens the same preview (or the
26
27
  * file) in a new tab, next to the card's download link.
27
28
  */
28
- export function FilePreview({ file, compact }: { file: PreviewFile; compact: boolean }) {
29
+ export function FilePreview({ file, compact, lang }: { file: PreviewFile; compact: boolean; lang?: UiLang }) {
30
+ const t = ui(lang);
29
31
  const [open, setOpen] = useState(false);
30
32
  const [loaded, setLoaded] = useState<Loaded>({ state: "idle" });
31
33
  const trigger = useRef<HTMLAnchorElement>(null);
@@ -130,13 +132,13 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
130
132
  {file.meta ? <div className="gf-meta">{file.meta}</div> : null}
131
133
  </div>
132
134
  <div className="gf-act">
133
- <a className="gf-btn icon" href={file.url} download={file.name} aria-label={`下载 ${file.name}`} title="下载">
135
+ <a className="gf-btn icon" href={file.url} download={file.name} aria-label={t.downloadNamed(file.name)} title={t.download}>
134
136
  <ActionGlyph name="download" />
135
137
  </a>
136
- <a className="gf-btn icon" href={file.url} target="_blank" rel="noopener noreferrer" aria-label={`在新标签页打开 ${file.name}`} title="在新标签页打开">
138
+ <a className="gf-btn icon" href={file.url} target="_blank" rel="noopener noreferrer" aria-label={t.openNamedInTab(file.name)} title={t.openInTab}>
137
139
  <ActionGlyph name="external" />
138
140
  </a>
139
- <button ref={closeButton} type="button" className="gf-btn icon" onClick={close} aria-label="关闭预览" title="关闭">
141
+ <button ref={closeButton} type="button" className="gf-btn icon" onClick={close} aria-label={t.closePreview} title={ui(lang).close.trim()}>
140
142
  <ActionGlyph name="close" />
141
143
  </button>
142
144
  </div>
@@ -147,14 +149,14 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
147
149
  <div className="markdown-body prose gf-doc" dangerouslySetInnerHTML={{ __html: loaded.html }} />
148
150
  ) : loaded.state === "error" ? (
149
151
  <div className="gf-state" role="alert">
150
- <p>预览没能载入。</p>
152
+ <p>{t.previewFailed}</p>
151
153
  <div className="gf-state-act">
152
- <button type="button" className="gf-btn" onClick={() => load()}>重试</button>
153
- <a className="gf-btn" href={file.url} download={file.name}>下载文件</a>
154
+ <button type="button" className="gf-btn" onClick={() => load()}>{t.retry}</button>
155
+ <a className="gf-btn" href={file.url} download={file.name}>{t.downloadFile}</a>
154
156
  </div>
155
157
  </div>
156
158
  ) : (
157
- <div className="gf-state" role="status">正在载入预览…</div>
159
+ <div className="gf-state" role="status">{t.previewLoading}</div>
158
160
  )}
159
161
  </div>
160
162
  ) : (
@@ -165,7 +167,7 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
165
167
  <span tabIndex={0} className="gf-sentinel" data-gf-sentinel="after" onFocus={fromAfter} />
166
168
  </div>
167
169
  )}
168
- <p className="gf-hint">Esc 关闭</p>
170
+ <p className="gf-hint">{t.escClose}</p>
169
171
  </aside>
170
172
  </div>,
171
173
  document.body,
@@ -183,10 +185,10 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
183
185
  data-compact={compact ? "true" : undefined}
184
186
  onClick={openDrawer}
185
187
  aria-haspopup="dialog"
186
- aria-label={`预览 ${file.name}`}
188
+ aria-label={t.previewNamed(file.name)}
187
189
  >
188
190
  <ActionGlyph name="eye" />
189
- <span className="gf-lb">预览</span>
191
+ <span className="gf-lb">{t.preview}</span>
190
192
  </a>
191
193
  {drawer}
192
194
  </>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useUiLang } from "../UiLang";
3
4
  import { readGardenPayload } from "../../lib/remark-garden.mjs";
4
5
  import {
5
6
  GardenCard,
@@ -17,6 +18,7 @@ import {
17
18
  import { GardenFile } from "./GardenFile";
18
19
 
19
20
  export function GardenBlock({ dataPayload }: { dataPayload?: string }) {
21
+ const lang = useUiLang();
20
22
  const block = readGardenPayload(dataPayload);
21
23
  if (!block) return null;
22
24
  if (block.legacy && block.suggestion) return <GardenLegacy suggestion={String(block.suggestion)} />;
@@ -42,7 +44,7 @@ export function GardenBlock({ dataPayload }: { dataPayload?: string }) {
42
44
  return <GardenInteractive block={{ id: block.id, props, children: block.children }} />;
43
45
  case "file":
44
46
  // Post bodies render file cards on the server (MarkdownArticle); this path has no Markdown preview.
45
- return <GardenFile props={props} />;
47
+ return <GardenFile props={props} lang={lang} />;
46
48
  default:
47
49
  return <GardenUnknown name={String(block.name)} />;
48
50
  }
@@ -1,4 +1,5 @@
1
1
  // Server component: the card for `garden file`. Only the 预览 control is a client island.
2
+ import { ui, type UiLang } from "../../lib/i18n";
2
3
  import { blockFiles, fileMeta } from "../../../ui/garden/file-block.mjs";
3
4
  import { ActionGlyph, FileGlyph, safeFileHref } from "./file-icons";
4
5
  import { FilePreview, type PreviewFile } from "./FilePreview";
@@ -19,7 +20,8 @@ type FileEntry = {
19
20
 
20
21
  const PREVIEWS = new Set(["md", "html", "pdf"]);
21
22
 
22
- export function GardenFile({ props }: { props: Record<string, unknown> }) {
23
+ export function GardenFile({ props, lang }: { props: Record<string, unknown>; lang?: UiLang }) {
24
+ const t = ui(lang);
23
25
  const block = blockFiles(props) as { layout?: string; title?: string; files: FileEntry[] };
24
26
  const files = block.files.filter((file) => safeFileHref(file.url));
25
27
  if (!files.length) return null;
@@ -29,7 +31,7 @@ export function GardenFile({ props }: { props: Record<string, unknown> }) {
29
31
  {group && block.title ? <div className="gf-title">{block.title}</div> : null}
30
32
  {files.map((file, index) => {
31
33
  const href = safeFileHref(file.url);
32
- const name = file.name || file.title || "文件";
34
+ const name = file.name || file.title || t.file;
33
35
  const title = file.title || name;
34
36
  const meta = fileMeta(file);
35
37
  const previewSrc = file.preview === "md" ? safeFileHref(file.previewSrc) : "";
@@ -44,20 +46,20 @@ export function GardenFile({ props }: { props: Record<string, unknown> }) {
44
46
  {meta ? <div className="gf-meta">{meta}</div> : null}
45
47
  </div>
46
48
  <div className="gf-act">
47
- {previewFile ? <FilePreview file={previewFile} compact={group} /> : null}
49
+ {previewFile ? <FilePreview file={previewFile} compact={group} lang={lang} /> : null}
48
50
  {file.remote ? (
49
- <a className="gf-btn icon" href={href} target="_blank" rel="noopener noreferrer" aria-label={`在新标签页打开 ${name}`} title="在新标签页打开">
51
+ <a className="gf-btn icon" href={href} target="_blank" rel="noopener noreferrer" aria-label={t.openNamedInTab(name)} title={t.openInTab}>
50
52
  <ActionGlyph name="external" />
51
53
  </a>
52
54
  ) : null}
53
55
  {group ? (
54
- <a className="gf-btn icon" href={href} download={file.remote ? undefined : name} aria-label={`下载 ${name}`} title="下载">
56
+ <a className="gf-btn icon" href={href} download={file.remote ? undefined : name} aria-label={t.downloadNamed(name)} title={t.download}>
55
57
  <ActionGlyph name="download" />
56
58
  </a>
57
59
  ) : (
58
- <a className="gf-btn pri" href={href} download={file.remote ? undefined : name} aria-label={`下载 ${name}`}>
60
+ <a className="gf-btn pri" href={href} download={file.remote ? undefined : name} aria-label={t.downloadNamed(name)}>
59
61
  <ActionGlyph name="download" />
60
- <span className="gf-lb">下载</span>
62
+ <span className="gf-lb">{t.download}</span>
61
63
  </a>
62
64
  )}
63
65
  </div>
@@ -2,11 +2,13 @@
2
2
 
3
3
  import { useEffect, useId, useRef, useState, type ReactNode } from "react";
4
4
  import ReactMarkdown from "react-markdown";
5
- import { stringify } from "yaml";
5
+ import { useUi, useUiLang } from "../UiLang";
6
+ import { langOf, ui } from "../../lib/i18n";
7
+ import { interactiveUiSource } from "../../../ui/core/garden-fence.mjs";
6
8
  import { markdownPlugins } from "../../lib/markdown-plugins";
7
9
  import { compileUiBlock } from "../../../ui/core/compile.mjs";
8
10
  import { splitCompareLine } from "../../../ui/garden/compare-line.mjs";
9
- import { UiErrorCard, UiFigure, type Diagnostic, type UiPayload } from "../../../ui/react";
11
+ import { RuntimeLangProvider, UiErrorCard, UiFigure, type Diagnostic, type UiPayload } from "../../../ui/react";
10
12
  import { FullscreenFrame, SandboxFrame } from "./chrome";
11
13
 
12
14
  function Rich({ text }: { text: string }) {
@@ -187,7 +189,8 @@ function chartRows(props: Record<string, unknown>) {
187
189
  export function GardenChart({ props }: { props: Record<string, unknown> }) {
188
190
  const rows = chartRows(props);
189
191
  const max = Math.max(...rows.map((row) => row.y), 1);
190
- const title = String(props.title ?? "图表");
192
+ const t = useUi();
193
+ const title = String(props.title ?? t.chart);
191
194
  return (
192
195
  <figure className="g-chart" role="group" aria-label={title}>
193
196
  {props.title ? <p className="g-chart-t">{title}</p> : null}
@@ -201,9 +204,9 @@ export function GardenChart({ props }: { props: Record<string, unknown> }) {
201
204
  ))}
202
205
  </div>
203
206
  <details className="g-chart-data">
204
- <summary>查看数据表</summary>
207
+ <summary>{t.dataTable}</summary>
205
208
  <table>
206
- <thead><tr><th>{String(props.xLabel ?? (typeof props.x === "string" ? props.x : "项目"))}</th><th>{String(props.yLabel ?? "数值")}</th></tr></thead>
209
+ <thead><tr><th>{String(props.xLabel ?? (typeof props.x === "string" ? props.x : t.item))}</th><th>{String(props.yLabel ?? t.value)}</th></tr></thead>
207
210
  <tbody>
208
211
  {rows.map((row) => <tr key={row.x}><td>{row.x}</td><td>{row.label}</td></tr>)}
209
212
  </tbody>
@@ -216,11 +219,11 @@ export function GardenChart({ props }: { props: Record<string, unknown> }) {
216
219
 
217
220
  type Part = { id: string; label: string; text: string };
218
221
 
219
- function partsOf(props: Record<string, unknown>): Part[] {
222
+ function partsOf(props: Record<string, unknown>, partLabel: (n: number) => string): Part[] {
220
223
  if (!Array.isArray(props.parts)) return [];
221
224
  return props.parts.map((part, index) => {
222
225
  const item = part as Record<string, unknown>;
223
- const label = String(item.label ?? item.name ?? `部分 ${index + 1}`);
226
+ const label = String(item.label ?? item.name ?? partLabel(index + 1));
224
227
  return { id: String(item.id ?? item.name ?? label), label, text: String(item.text ?? "") };
225
228
  });
226
229
  }
@@ -240,7 +243,8 @@ function partOn(selected: string, token: string) {
240
243
  }
241
244
 
242
245
  export function GardenFigure({ props, children }: { props: Record<string, unknown>; children?: string }) {
243
- const parts = partsOf(props);
246
+ const t = useUi();
247
+ const parts = partsOf(props, t.part);
244
248
  const [selected, setSelected] = useState(parts[0]?.id ?? "all");
245
249
  const svgRef = useRef<HTMLDivElement>(null);
246
250
  const base = useId();
@@ -286,7 +290,7 @@ export function GardenFigure({ props, children }: { props: Record<string, unknow
286
290
  const body = (
287
291
  <figure className="g-explore" data-g="explore" data-sel={selected} ref={svgRef}>
288
292
  {parts.length ? (
289
- <div role="tablist" aria-label="选择一个部分">
293
+ <div role="tablist" aria-label={t.pickPart}>
290
294
  {parts.map((part, index) => (
291
295
  <button
292
296
  key={part.id}
@@ -324,7 +328,7 @@ export function GardenFigure({ props, children }: { props: Record<string, unknow
324
328
  {typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
325
329
  </figure>
326
330
  );
327
- return parts.length || svg ? <FullscreenFrame label="结构图">{body}</FullscreenFrame> : body;
331
+ return parts.length || svg ? <FullscreenFrame label={t.diagram}>{body}</FullscreenFrame> : body;
328
332
  }
329
333
 
330
334
  function htmlHasScript(source: string) {
@@ -336,6 +340,8 @@ function isLightWidget(source: string) {
336
340
  }
337
341
 
338
342
  function bindGardenWidgets(root: HTMLElement) {
343
+ const attr = root.closest("[lang]")?.getAttribute("lang");
344
+ const t = ui(attr ? langOf(attr) : undefined);
339
345
  root.querySelectorAll<HTMLElement>("[data-g=tabs]").forEach((widget) => {
340
346
  const list = widget.querySelector<HTMLElement>(":scope > [role=tablist]");
341
347
  const tabs = [...(list?.querySelectorAll<HTMLElement>(":scope > [role=tab]") ?? [])];
@@ -373,12 +379,12 @@ function bindGardenWidgets(root: HTMLElement) {
373
379
  const chosen = form.querySelector<HTMLInputElement>("input:checked");
374
380
  if (!chosen) {
375
381
  out.className = "g-quiz-out";
376
- out.textContent = "先选一个答案。";
382
+ out.textContent = t.pickAnswer;
377
383
  return;
378
384
  }
379
385
  const correct = chosen.value === form.dataset.answer;
380
386
  out.className = `g-quiz-out ${correct ? "ok" : "no"}`;
381
- out.textContent = correct ? "✓ 答对了" : "✗ 再想想";
387
+ out.textContent = correct ? t.correct : t.wrong;
382
388
  if (explain) explain.hidden = !correct;
383
389
  });
384
390
  });
@@ -446,7 +452,7 @@ function bindGardenWidgets(root: HTMLElement) {
446
452
  const equation = calc.querySelector("[data-calc]");
447
453
  if (equation && spec.show) {
448
454
  const shown = spec.show.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key]));
449
- equation.textContent = `${shown} = ${spec.format === "GB" ? `${Math.round(result).toLocaleString("en-US")} 字节` : String(Math.round(result * 1e4) / 1e4)}`;
455
+ equation.textContent = `${shown} = ${spec.format === "GB" ? `${Math.round(result).toLocaleString("en-US")} ${t.bytes}` : String(Math.round(result * 1e4) / 1e4)}`;
450
456
  }
451
457
  };
452
458
  calc.addEventListener("input", run);
@@ -484,9 +490,10 @@ function LightGardenHtml({ code }: { code: string }) {
484
490
 
485
491
  export function GardenHtml({ props, children }: { props: Record<string, unknown>; children?: string }) {
486
492
  const code = typeof props.code === "string" ? props.code : children || "";
493
+ const t = useUi();
487
494
  const title = String(props.title ?? "HTML");
488
495
  if (!code && typeof props.src === "string") {
489
- return <p className="g-embed-note">HTML 在 {props.src}。发布时由同名目录读入。</p>;
496
+ return <p className="g-embed-note">{t.htmlAt(String(props.src))}</p>;
490
497
  }
491
498
  if (htmlHasScript(code)) {
492
499
  return <SandboxFrame html={code} title={title} />;
@@ -499,36 +506,38 @@ export function GardenHtml({ props, children }: { props: Record<string, unknown>
499
506
  }
500
507
 
501
508
  export function GardenInteractive({ block }: { block: { id?: string | null; props: Record<string, unknown>; children?: string } }) {
502
- const header = {
503
- id: block.id || block.props.id || "interactive",
504
- caption: block.props.caption ?? "",
505
- intent: block.props.intent ?? "",
506
- ...(block.props.expect ? { expect: block.props.expect } : {}),
507
- };
508
- const source = `---\n${stringify(header)}---\n${block.children ?? ""}`;
509
+ const t = useUi();
510
+ const lang = useUiLang();
511
+ // Same source `garden ui check` validates (ui/core/garden-fence.mjs).
512
+ const { source, header } = interactiveUiSource({ id: block.id ?? null, props: block.props, children: block.children ?? "" });
509
513
  const { payload, check } = compileUiBlock(source);
510
514
  if (!payload) {
511
515
  return (
516
+ <RuntimeLangProvider lang={lang}>
512
517
  <UiErrorCard
513
518
  id={String(header.id)}
514
519
  caption={String(header.caption)}
515
520
  diagnostics={(check.diagnostics ?? []) as Diagnostic[]}
516
521
  />
522
+ </RuntimeLangProvider>
517
523
  );
518
524
  }
519
525
  return (
520
- <FullscreenFrame label={String(header.caption || "交互")}>
526
+ <RuntimeLangProvider lang={lang}>
527
+ <FullscreenFrame label={String(header.caption || t.interactive)}>
521
528
  <div className="g-play">
522
529
  <UiFigure payload={payload as UiPayload} />
523
530
  </div>
524
531
  </FullscreenFrame>
532
+ </RuntimeLangProvider>
525
533
  );
526
534
  }
527
535
 
528
536
  export function GardenIdea({ text }: { text: string }) {
537
+ const t = useUi();
529
538
  return (
530
539
  <aside className="g-idea">
531
- <p className="g-example-label">想法 · 发布前编译</p>
540
+ <p className="g-example-label">{t.ideaLabel}</p>
532
541
  <p>{text}</p>
533
542
  </aside>
534
543
  );
@@ -539,7 +548,8 @@ export function GardenLegacy({ suggestion }: { suggestion: string }) {
539
548
  }
540
549
 
541
550
  export function GardenUnknown({ name }: { name: string }) {
542
- return <p className="g-embed-note">未知 Garden 块:{name}</p>;
551
+ const t = useUi();
552
+ return <p className="g-embed-note">{t.unknownBlock(name)}</p>;
543
553
  }
544
554
 
545
555
  export function GardenMessage({ children }: { children: ReactNode }) {
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { useUi } from "../UiLang";
4
+
3
5
  import {
4
6
  useEffect,
5
7
  useId,
@@ -47,7 +49,9 @@ function writeClipboard(text: string) {
47
49
  });
48
50
  }
49
51
 
50
- export function CopyButton({ getText, label = "复制代码" }: { getText: () => string; label?: string }) {
52
+ export function CopyButton({ getText, label }: { getText: () => string; label?: string }) {
53
+ const t = useUi();
54
+ label = label ?? t.copyCode;
51
55
  const [done, setDone] = useState(false);
52
56
  const timer = useRef<number>(0);
53
57
 
@@ -56,8 +60,8 @@ export function CopyButton({ getText, label = "复制代码" }: { getText: () =>
56
60
  <button
57
61
  type="button"
58
62
  className={`ux-btn ux-copy${done ? " ux-done" : ""}`}
59
- aria-label={done ? "已复制" : label}
60
- title={done ? "已复制" : label}
63
+ aria-label={done ? t.copied : label}
64
+ title={done ? t.copied : label}
61
65
  onClick={() => {
62
66
  const text = getText();
63
67
  writeClipboard(text).then(() => {
@@ -67,9 +71,9 @@ export function CopyButton({ getText, label = "复制代码" }: { getText: () =>
67
71
  }).catch(() => setDone(false));
68
72
  }}
69
73
  >
70
- {done ? "已复制" : COPY_ICON}
74
+ {done ? t.copied : COPY_ICON}
71
75
  </button>
72
- <span className="ux-live" aria-live="polite">{done ? "已复制到剪贴板" : ""}</span>
76
+ <span className="ux-live" aria-live="polite">{done ? t.copiedLive : ""}</span>
73
77
  </>
74
78
  );
75
79
  }
@@ -88,12 +92,12 @@ export function CodeCopy({ children }: { children: ReactNode }) {
88
92
  );
89
93
  }
90
94
 
91
- function diffLines(source: string) {
95
+ function diffLines(source: string, t: ReturnType<typeof useUi>) {
92
96
  return source.replace(/\n$/u, "").split("\n").map((line) => {
93
97
  const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx";
94
98
  const body = kind === "ctx" ? line.replace(/^ /u, "") : line.slice(1);
95
99
  const mark = kind === "add" ? "+" : kind === "del" ? "−" : " ";
96
- const spoken = kind === "add" ? "(新增)" : kind === "del" ? "(删除)" : "";
100
+ const spoken = kind === "add" ? t.diffAdded : kind === "del" ? t.diffRemoved : "";
97
101
  return { kind, body, mark, spoken };
98
102
  });
99
103
  }
@@ -109,11 +113,12 @@ export function CodeDiff({
109
113
  }) {
110
114
  const [tab, setTab] = useState(0);
111
115
  const base = useId();
112
- const tabs = ["代码", "对比上一版"];
116
+ const t = useUi();
117
+ const tabs = t.codeTabs;
113
118
 
114
119
  return (
115
120
  <div className="g-tabs g-code-group g-codediff" data-g="tabs">
116
- <div role="tablist" aria-label="代码">
121
+ <div role="tablist" aria-label={t.codeTabsLabel}>
117
122
  {tabs.map((label, index) => (
118
123
  <button
119
124
  key={label}
@@ -146,7 +151,7 @@ export function CodeDiff({
146
151
  <div role="tabpanel" id={`${base}-p1`} aria-labelledby={`${base}-t1`} hidden={tab !== 1}>
147
152
  <pre className="code g-diff">
148
153
  <code>
149
- {diffLines(diff).map((line, index) => (
154
+ {diffLines(diff, t).map((line, index) => (
150
155
  <span className={`d-${line.kind}`} key={index}>
151
156
  <span className="d-m" aria-hidden="true">{line.mark}</span>
152
157
  {line.body}
@@ -164,7 +169,9 @@ function fullscreenElement() {
164
169
  return document.fullscreenElement || (document as Document & { webkitFullscreenElement?: Element }).webkitFullscreenElement || null;
165
170
  }
166
171
 
167
- export function FullscreenFrame({ children, label = "演示" }: { children: ReactNode; label?: string }) {
172
+ export function FullscreenFrame({ children, label }: { children: ReactNode; label?: string }) {
173
+ const t = useUi();
174
+ label = label ?? t.demo;
168
175
  const ref = useRef<HTMLDivElement>(null);
169
176
  const buttonRef = useRef<HTMLButtonElement>(null);
170
177
  const restoreRef = useRef<HTMLElement | null>(null);
@@ -255,8 +262,8 @@ export function FullscreenFrame({ children, label = "演示" }: { children: Reac
255
262
  ref={buttonRef}
256
263
  type="button"
257
264
  className="ux-btn ux-fsb"
258
- aria-label={active ? "退出全屏" : "全屏"}
259
- title={active ? "退出全屏" : "全屏"}
265
+ aria-label={active ? t.exitFullscreen : t.fullscreen}
266
+ title={active ? t.exitFullscreen : t.fullscreen}
260
267
  onClick={() => (active ? close() : open())}
261
268
  >
262
269
  {active ? EXIT_ICON : ENTER_ICON}