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.
- package/.agents/skills/garden-ui/SKILL.md +26 -19
- package/README.en.md +57 -0
- package/README.md +31 -46
- package/app/blog/[...path]/page.tsx +9 -1
- package/app/columns/[column]/[...path]/page.tsx +10 -12
- package/app/components/ArticlePager.tsx +32 -0
- package/app/components/Comments.tsx +2 -1
- package/app/components/FeedMore.tsx +70 -0
- package/app/components/GardenDocsNav.tsx +25 -20
- package/app/components/HandbookLangRedirect.tsx +30 -0
- package/app/components/HandbookLangSwitch.tsx +49 -0
- package/app/components/HandbookView.tsx +187 -0
- package/app/components/MarkdownArticle.tsx +11 -5
- package/app/components/MarkdownImage.tsx +6 -4
- package/app/components/SiteChrome.tsx +11 -2
- package/app/components/SiteFooter.tsx +5 -3
- package/app/components/SiteHeader.tsx +27 -23
- package/app/components/TableOfContents.tsx +15 -9
- package/app/components/ThoughtFeed.tsx +41 -0
- package/app/components/UiLang.tsx +18 -0
- package/app/components/Webmentions.tsx +3 -2
- package/app/components/garden/FilePreview.tsx +13 -11
- package/app/components/garden/GardenBlock.tsx +3 -1
- package/app/components/garden/GardenFile.tsx +9 -7
- package/app/components/garden/blocks.tsx +34 -24
- package/app/components/garden/chrome.tsx +20 -13
- package/app/garden/[...slug]/page.tsx +10 -130
- package/app/garden/en/[...slug]/page.tsx +28 -0
- package/app/garden/en/page.tsx +16 -0
- package/app/garden/page.tsx +7 -74
- package/app/garden-modules.d.ts +3 -0
- package/app/layout.tsx +2 -4
- package/app/lib/content.ts +12 -2
- package/app/lib/garden-docs-meta.ts +60 -5
- package/app/lib/handbook-path.ts +11 -0
- package/app/lib/i18n.ts +222 -0
- package/app/lib/page-alternates.ts +2 -2
- package/app/not-found.tsx +5 -3
- package/app/site.config.ts +1 -1
- package/app/styles/a2.css +101 -32
- package/app/styles/shell.css +6 -0
- package/app/thoughts/page/[n]/page.tsx +44 -0
- package/app/thoughts/page.tsx +2 -6
- package/bin/garden.mjs +4 -0
- package/docs/handbook/ai-writing.md +99 -0
- package/docs/handbook/components/card.md +2 -0
- package/docs/handbook/components/chart.md +2 -0
- package/docs/handbook/components/compare.md +10 -12
- package/docs/handbook/components/figure.md +2 -0
- package/docs/handbook/components/file.md +2 -0
- package/docs/handbook/components/html.md +2 -0
- package/docs/handbook/components/interactive.md +2 -0
- package/docs/handbook/components/stat.md +2 -0
- package/docs/handbook/components/timeline.md +2 -0
- package/docs/handbook/components.md +1 -1
- package/docs/handbook/config.md +2 -2
- package/docs/handbook/deploy.md +1 -1
- package/docs/handbook/design.md +3 -3
- package/docs/handbook/en/ai-writing.md +99 -0
- package/docs/handbook/en/components/card.md +46 -0
- package/docs/handbook/en/components/chart.md +54 -0
- package/docs/handbook/en/components/compare.md +38 -0
- package/docs/handbook/en/components/figure.md +72 -0
- package/docs/handbook/en/components/file/checklist.md +17 -0
- package/docs/handbook/en/components/file.md +34 -0
- package/docs/handbook/en/components/html.md +69 -0
- package/docs/handbook/en/components/interactive.md +67 -0
- package/docs/handbook/en/components/stat.md +57 -0
- package/docs/handbook/en/components/timeline.md +52 -0
- package/docs/handbook/en/components.md +30 -0
- package/docs/handbook/en/config.md +125 -0
- package/docs/handbook/en/deploy.md +273 -0
- package/docs/handbook/en/design.md +81 -0
- package/docs/handbook/en/index.md +192 -0
- package/docs/handbook/en/start.md +92 -0
- package/docs/handbook/en/workflow.md +81 -0
- package/docs/handbook/en/writing.md +156 -0
- package/docs/handbook/index.md +98 -3
- package/docs/handbook/writing.md +18 -10
- package/package.json +1 -1
- package/scripts/build-content.mjs +23 -3
- package/scripts/handbook-facts.mjs +34 -0
- package/scripts/html-lang.mjs +36 -0
- package/scripts/static-404.mjs +25 -0
- package/scripts/ui-cli.mjs +14 -8
- package/templates/site/site.config.yaml +2 -0
- package/ui/core/apply.mjs +22 -4
- package/ui/core/cli-locale.mjs +67 -0
- package/ui/core/fence.mjs +18 -3
- package/ui/core/fix-prompt.mjs +18 -15
- package/ui/core/garden-fence.mjs +64 -0
- package/ui/core/scan.mjs +19 -3
- package/ui/garden/adjacent.mjs +21 -0
- package/ui/garden/check.mjs +6 -0
- package/ui/garden/html-lang.mjs +16 -0
- package/ui/garden/paging.mjs +30 -0
- package/ui/preview/dist/garden-ui.css +9 -9
- package/ui/preview/dist/garden-ui.esm.js +169 -169
- package/ui/preview/dist/garden-ui.iife.js +169 -169
- package/ui/react/components/chart.tsx +7 -4
- package/ui/react/components/display.tsx +6 -3
- package/ui/react/components/inputs.tsx +6 -3
- package/ui/react/components/layout.tsx +6 -4
- package/ui/react/components/media.tsx +7 -4
- package/ui/react/components/sandbox.tsx +3 -1
- package/ui/react/index.tsx +11 -7
- package/ui/react/lang.tsx +85 -0
- package/ui/react/runtime.tsx +3 -1
- package/ui/react/ui.css +9 -9
- 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={
|
|
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={
|
|
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
|
|
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={
|
|
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
|
|
418
|
+
<span>{t.tocShort}</span>
|
|
413
419
|
</a>
|
|
414
420
|
<button
|
|
415
421
|
className="toc-mobile-backdrop"
|
|
416
422
|
type="button"
|
|
417
|
-
aria-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={
|
|
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={
|
|
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"
|
|
97
|
-
<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={
|
|
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={
|
|
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=
|
|
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
|
|
152
|
+
<p>{t.previewFailed}</p>
|
|
151
153
|
<div className="gf-state-act">
|
|
152
|
-
<button type="button" className="gf-btn" onClick={() => load()}
|
|
153
|
-
<a className="gf-btn" href={file.url} download={file.name}
|
|
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"
|
|
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">
|
|
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={
|
|
188
|
+
aria-label={t.previewNamed(file.name)}
|
|
187
189
|
>
|
|
188
190
|
<ActionGlyph name="eye" />
|
|
189
|
-
<span className="gf-lb"
|
|
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={
|
|
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={
|
|
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={
|
|
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"
|
|
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 {
|
|
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
|
|
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
|
|
207
|
+
<summary>{t.dataTable}</summary>
|
|
205
208
|
<table>
|
|
206
|
-
<thead><tr><th>{String(props.xLabel ?? (typeof props.x === "string" ? props.x :
|
|
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
|
|
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 ??
|
|
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
|
|
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=
|
|
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")}
|
|
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">
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
<
|
|
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"
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
60
|
-
title={done ?
|
|
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 ?
|
|
74
|
+
{done ? t.copied : COPY_ICON}
|
|
71
75
|
</button>
|
|
72
|
-
<span className="ux-live" aria-live="polite">{done ?
|
|
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" ?
|
|
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
|
|
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
|
|
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}
|