hd-garden 0.3.13 → 0.4.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.
- package/.agents/skills/garden-interactive-chart/SKILL.md +6 -6
- package/.agents/skills/garden-ui/SKILL.md +4 -4
- package/app/a2/[slug]/page.tsx +61 -0
- package/app/a2/page.tsx +27 -0
- package/app/blog/[...path]/page.tsx +32 -22
- package/app/blog/page.tsx +6 -7
- package/app/columns/[column]/[...path]/page.tsx +32 -25
- package/app/columns/page.tsx +38 -52
- package/app/components/ArticleMeasure.tsx +95 -0
- package/app/components/ArticleTitle.tsx +20 -0
- package/app/components/BlogArchive.tsx +106 -102
- package/app/components/MarkdownArticle.tsx +145 -66
- package/app/components/MermaidDiagram.tsx +44 -71
- package/app/components/SiteFooter.tsx +3 -7
- package/app/components/SiteHeader.tsx +55 -41
- package/app/components/TableOfContents.tsx +3 -2
- package/app/components/TagIndex.tsx +97 -0
- package/app/components/garden/GardenBlock.tsx +45 -0
- package/app/components/garden/blocks.tsx +530 -0
- package/app/components/garden/chrome.tsx +350 -0
- package/app/components/garden/sandbox-message.ts +16 -0
- package/app/garden-modules.d.ts +2 -0
- package/app/globals.css +17 -12
- package/app/layout.tsx +9 -0
- package/app/lib/a2-meta.ts +22 -0
- package/app/lib/a2-preview.ts +50 -0
- package/app/lib/content.ts +52 -6
- package/app/lib/markdown-plugins.ts +2 -0
- package/app/lib/remark-a2.mjs +208 -0
- package/app/lib/remark-garden.mjs +88 -0
- package/app/page.tsx +122 -93
- package/app/styles/a2.css +1383 -0
- package/app/styles/shell.css +438 -0
- package/app/styles/tokens.css +111 -0
- package/app/tags/page.tsx +28 -0
- package/app/ui-parity/page.tsx +3 -2
- package/package.json +1 -1
- package/scripts/build-content.mjs +11 -1
- package/scripts/check-post.mjs +18 -4
- package/scripts/new-post.mjs +3 -0
- package/site.config.yaml +1 -1
- package/templates/garden-blocks.md +128 -0
- package/templates/garden-snippets.plist +121 -0
- package/ui/garden/catalog.mjs +196 -0
- package/ui/garden/check.mjs +103 -0
- package/ui/garden/enhance.mjs +89 -0
- package/ui/garden/parse.mjs +198 -0
- package/ui/garden/snippets.mjs +109 -0
- package/ui/garden/title.mjs +37 -0
- package/ui/garden/word-count.mjs +266 -0
- package/ui/host.mjs +45 -28
- package/ui/parity-fixtures.mjs +1 -1
- package/ui/preview/dist/garden-ui.css +81 -77
- package/ui/preview/dist/garden-ui.esm.js +124 -89
- package/ui/preview/dist/garden-ui.iife.js +124 -89
- package/ui/prompt/rules.md +17 -1
- package/ui/react/CatalogPage.tsx +12 -0
- package/ui/react/components/display.tsx +1 -1
- package/ui/react/components/media.tsx +2 -2
- package/ui/react/ui.css +81 -77
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import { stringify } from "yaml";
|
|
6
|
+
import { markdownPlugins } from "../../lib/markdown-plugins";
|
|
7
|
+
import { compileUiBlock } from "../../../ui/core/compile.mjs";
|
|
8
|
+
import { UiErrorCard, UiFigure, type Diagnostic, type UiPayload } from "../../../ui/react";
|
|
9
|
+
import { FullscreenFrame, SandboxFrame } from "./chrome";
|
|
10
|
+
|
|
11
|
+
function Rich({ text }: { text: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<ReactMarkdown remarkPlugins={markdownPlugins} components={{ p: ({ children }) => <>{children}</> }}>
|
|
14
|
+
{String(text ?? "")}
|
|
15
|
+
</ReactMarkdown>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hostOf(url: string) {
|
|
20
|
+
try {
|
|
21
|
+
return new URL(url).hostname.replace(/^www\./u, "");
|
|
22
|
+
} catch {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function SourceChip({ source }: { source: unknown }) {
|
|
28
|
+
const list = (Array.isArray(source) ? source : source ? [source] : []).map((item) => {
|
|
29
|
+
if (typeof item === "string") return { url: item, site: hostOf(item) };
|
|
30
|
+
if (item && typeof item === "object" && "url" in item) {
|
|
31
|
+
const url = String((item as { url?: unknown }).url ?? "");
|
|
32
|
+
const site = String((item as { site?: unknown }).site ?? hostOf(url));
|
|
33
|
+
return { url, site };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}).filter((item): item is { url: string; site: string } => Boolean(item?.url));
|
|
37
|
+
if (!list.length) return null;
|
|
38
|
+
return (
|
|
39
|
+
<span className="g-srcs">
|
|
40
|
+
<a className="g-src ext" href={list[0].url} target="_blank" rel="noopener noreferrer">
|
|
41
|
+
<span className="g-src-ic" aria-hidden="true">{[...list[0].site][0] || "·"}</span>
|
|
42
|
+
<span>{list[0].site}</span>
|
|
43
|
+
</a>
|
|
44
|
+
{list.length > 1 ? <span className="g-src-more">+{list.length - 1}</span> : null}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function itemsOf(props: Record<string, unknown>) {
|
|
50
|
+
return Array.isArray(props.items) ? props.items as Record<string, unknown>[] : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function GardenCard({ props }: { props: Record<string, unknown> }) {
|
|
54
|
+
const url = typeof props.url === "string" ? props.url : "";
|
|
55
|
+
const title = String(props.title ?? url);
|
|
56
|
+
const description = typeof props.description === "string" ? props.description : typeof props.text === "string" ? props.text : "";
|
|
57
|
+
const points = Array.isArray(props.points) ? props.points : Array.isArray(props.items) ? props.items : [];
|
|
58
|
+
return (
|
|
59
|
+
<article className="g-entity">
|
|
60
|
+
{typeof props.image === "string" ? (
|
|
61
|
+
<div className="g-entity-img">
|
|
62
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
63
|
+
<img src={props.image} alt={String(props.alt ?? title)} />
|
|
64
|
+
</div>
|
|
65
|
+
) : null}
|
|
66
|
+
<div className="g-entity-b">
|
|
67
|
+
<h4 className="g-entity-t">{url ? <a className="ext" href={url} target="_blank" rel="noopener noreferrer"><Rich text={title} /></a> : <Rich text={title} />}</h4>
|
|
68
|
+
{typeof props.meta === "string" ? <p className="g-entity-m"><Rich text={props.meta} /></p> : null}
|
|
69
|
+
{description ? <p><Rich text={description} /> <SourceChip source={props.source} /></p> : null}
|
|
70
|
+
</div>
|
|
71
|
+
{points.length ? (
|
|
72
|
+
<ol className="g-entity-list">
|
|
73
|
+
{points.map((point, index) => {
|
|
74
|
+
const item = typeof point === "string" ? { title: point } : point as Record<string, unknown>;
|
|
75
|
+
return (
|
|
76
|
+
<li key={index}>
|
|
77
|
+
<p className="g-entity-lt"><Rich text={String(item.title ?? "")} /></p>
|
|
78
|
+
{typeof item.text === "string" ? <p><Rich text={item.text} /></p> : null}
|
|
79
|
+
</li>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</ol>
|
|
83
|
+
) : null}
|
|
84
|
+
</article>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function GardenStat({ props }: { props: Record<string, unknown> }) {
|
|
89
|
+
const items = itemsOf(props).length ? itemsOf(props) : [props];
|
|
90
|
+
const rich = items.some((item) => item.caption || item.source || item.text);
|
|
91
|
+
return (
|
|
92
|
+
<div className={`g-stats${rich ? " g-stats-rich" : ""}`}>
|
|
93
|
+
{items.map((item, index) => (
|
|
94
|
+
<div className="g-stat" key={index}>
|
|
95
|
+
{item.label ? <div className="g-stat-l"><Rich text={String(item.label)} /></div> : null}
|
|
96
|
+
<div className="g-stat-n">
|
|
97
|
+
{String(item.value ?? "")}
|
|
98
|
+
{item.unit ? <small>{String(item.unit)}</small> : null}
|
|
99
|
+
</div>
|
|
100
|
+
{item.caption ? <div className="g-stat-c"><Rich text={String(item.caption)} /></div> : null}
|
|
101
|
+
{item.text || item.source ? <p className="g-stat-t">{item.text ? <Rich text={String(item.text)} /> : null} <SourceChip source={item.source} /></p> : null}
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function GardenCompare({ props }: { props: Record<string, unknown> }) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="g-compare" role="group">
|
|
111
|
+
{itemsOf(props).map((item, index) => {
|
|
112
|
+
const { title, text, ...rest } = item;
|
|
113
|
+
const texts = text == null ? [] : Array.isArray(text) ? text : [text];
|
|
114
|
+
return (
|
|
115
|
+
<section className="g-cmp" key={index}>
|
|
116
|
+
{title ? <h4><Rich text={String(title)} /></h4> : null}
|
|
117
|
+
{texts.map((paragraph, paragraphIndex) => <p key={paragraphIndex}><Rich text={String(paragraph)} /></p>)}
|
|
118
|
+
{Object.keys(rest).length ? (
|
|
119
|
+
<dl>
|
|
120
|
+
{Object.entries(rest).map(([key, value]) => (
|
|
121
|
+
<span key={key}>
|
|
122
|
+
<dt><Rich text={key} /></dt>
|
|
123
|
+
<dd><Rich text={String(value)} /></dd>
|
|
124
|
+
</span>
|
|
125
|
+
))}
|
|
126
|
+
</dl>
|
|
127
|
+
) : null}
|
|
128
|
+
</section>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function GardenTimeline({ props }: { props: Record<string, unknown> }) {
|
|
136
|
+
return (
|
|
137
|
+
<figure className="g-timeline">
|
|
138
|
+
{typeof props.title === "string" ? <p className="g-timeline-h"><Rich text={props.title} /></p> : null}
|
|
139
|
+
<ol>
|
|
140
|
+
{itemsOf(props).map((item, index) => (
|
|
141
|
+
<li key={index}>
|
|
142
|
+
{item.date ? <p className="g-tl-d"><Rich text={String(item.date)} /></p> : null}
|
|
143
|
+
<p className="g-tl-t"><Rich text={String(item.title ?? "")} /></p>
|
|
144
|
+
{item.text ? <p className="g-tl-x"><Rich text={String(item.text)} /> <SourceChip source={item.source} /></p> : null}
|
|
145
|
+
</li>
|
|
146
|
+
))}
|
|
147
|
+
</ol>
|
|
148
|
+
</figure>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function chartRows(props: Record<string, unknown>) {
|
|
153
|
+
if (Array.isArray(props.data) && props.data.every((row) => row && typeof row === "object" && "x" in (row as object))) {
|
|
154
|
+
return (props.data as { x: unknown; y: unknown; label?: unknown }[]).map((row) => ({
|
|
155
|
+
x: String(row.x),
|
|
156
|
+
y: Number(row.y) || 0,
|
|
157
|
+
label: String(row.label ?? `${row.y}${props.unit ? ` ${props.unit}` : ""}`),
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
const series = Array.isArray(props.series) ? props.series[0] as { data?: unknown[]; name?: string } : null;
|
|
161
|
+
const values = Array.isArray(series?.data) ? series.data : Array.isArray(props.data) ? props.data : [];
|
|
162
|
+
const labels = Array.isArray(props.x) ? props.x.map(String) : null;
|
|
163
|
+
return values.map((value, index) => ({
|
|
164
|
+
x: labels?.[index] ?? String(index + 1),
|
|
165
|
+
y: Number(value) || 0,
|
|
166
|
+
label: `${value}${props.unit ? ` ${props.unit}` : ""}`,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function GardenChart({ props }: { props: Record<string, unknown> }) {
|
|
171
|
+
const rows = chartRows(props);
|
|
172
|
+
const max = Math.max(...rows.map((row) => row.y), 1);
|
|
173
|
+
const title = String(props.title ?? "图表");
|
|
174
|
+
return (
|
|
175
|
+
<figure className="g-chart" role="group" aria-label={title}>
|
|
176
|
+
{props.title ? <p className="g-chart-t">{title}</p> : null}
|
|
177
|
+
<div className="g-bars" aria-hidden="true">
|
|
178
|
+
{rows.map((row) => (
|
|
179
|
+
<div className="g-bar" key={row.x}>
|
|
180
|
+
<span className="g-bar-k">{row.x}</span>
|
|
181
|
+
<span className="g-bar-track"><span className="g-bar-fill" style={{ width: `${(row.y / max) * 100}%` }} /></span>
|
|
182
|
+
<span className="g-bar-v">{row.label}</span>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
<details className="g-chart-data">
|
|
187
|
+
<summary>查看数据表</summary>
|
|
188
|
+
<table>
|
|
189
|
+
<thead><tr><th>{String(props.xLabel ?? (typeof props.x === "string" ? props.x : "项目"))}</th><th>{String(props.yLabel ?? "数值")}</th></tr></thead>
|
|
190
|
+
<tbody>
|
|
191
|
+
{rows.map((row) => <tr key={row.x}><td>{row.x}</td><td>{row.label}</td></tr>)}
|
|
192
|
+
</tbody>
|
|
193
|
+
</table>
|
|
194
|
+
</details>
|
|
195
|
+
{typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
|
|
196
|
+
</figure>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type Part = { id: string; label: string; text: string };
|
|
201
|
+
|
|
202
|
+
function partsOf(props: Record<string, unknown>): Part[] {
|
|
203
|
+
if (!Array.isArray(props.parts)) return [];
|
|
204
|
+
return props.parts.map((part, index) => {
|
|
205
|
+
const item = part as Record<string, unknown>;
|
|
206
|
+
const label = String(item.label ?? item.name ?? `部分 ${index + 1}`);
|
|
207
|
+
return { id: String(item.id ?? item.name ?? label), label, text: String(item.text ?? "") };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function partOn(selected: string, token: string) {
|
|
212
|
+
if (selected === "all" || selected === "全部") return true;
|
|
213
|
+
const parts = token.trim().split(/\s+/u).filter(Boolean);
|
|
214
|
+
const has = (id: string) => parts.includes(id);
|
|
215
|
+
const starts = (prefix: string) => parts.some((part) => part === prefix || part.startsWith(prefix));
|
|
216
|
+
if (has(selected)) return true;
|
|
217
|
+
if (selected === "q") return has("attn");
|
|
218
|
+
if (selected === "k") return starts("k") || has("attn");
|
|
219
|
+
if (selected === "v") return starts("v") || has("out");
|
|
220
|
+
if (selected === "cache") return has("cache") || has("kn") || has("vn") || starts("k") || starts("v");
|
|
221
|
+
if (selected === "out") return has("out");
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function GardenFigure({ props, children }: { props: Record<string, unknown>; children?: string }) {
|
|
226
|
+
const parts = partsOf(props);
|
|
227
|
+
const [selected, setSelected] = useState(parts[0]?.id ?? "all");
|
|
228
|
+
const svgRef = useRef<HTMLDivElement>(null);
|
|
229
|
+
const base = useId();
|
|
230
|
+
const fromChild = typeof children === "string" && children.includes("<svg") ? children : "";
|
|
231
|
+
const svg = typeof props.svg === "string" && props.svg.includes("<svg") ? props.svg : fromChild;
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
const root = svgRef.current;
|
|
235
|
+
const drawing = root?.querySelector("svg");
|
|
236
|
+
if (!drawing) return;
|
|
237
|
+
const shapes = [...drawing.querySelectorAll<SVGGraphicsElement>("rect[data-p], ellipse[data-p], circle[data-p]")];
|
|
238
|
+
drawing.querySelectorAll("text").forEach((text) => {
|
|
239
|
+
const x = Number(text.getAttribute("x"));
|
|
240
|
+
const y = Number(text.getAttribute("y"));
|
|
241
|
+
const shape = shapes.find((item) => {
|
|
242
|
+
const box = item.getBBox();
|
|
243
|
+
return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height + 2;
|
|
244
|
+
});
|
|
245
|
+
if (!shape) return;
|
|
246
|
+
if (!text.hasAttribute("data-p")) text.setAttribute("data-p", shape.getAttribute("data-p") || "");
|
|
247
|
+
try {
|
|
248
|
+
const room = shape.getBBox().width - 12;
|
|
249
|
+
const width = text.getComputedTextLength();
|
|
250
|
+
if (width > room && room > 0) {
|
|
251
|
+
const size = Number.parseFloat(getComputedStyle(text).fontSize);
|
|
252
|
+
text.style.fontSize = `${(size * room / width).toFixed(2)}px`;
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
/* getBBox can fail before layout */
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}, [svg]);
|
|
259
|
+
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
const root = svgRef.current;
|
|
262
|
+
if (!root) return;
|
|
263
|
+
root.querySelectorAll("[data-p]").forEach((element) => {
|
|
264
|
+
const token = ` ${element.getAttribute("data-p") || ""} `;
|
|
265
|
+
element.classList.toggle("on", partOn(selected, token));
|
|
266
|
+
});
|
|
267
|
+
}, [selected, svg]);
|
|
268
|
+
|
|
269
|
+
const body = (
|
|
270
|
+
<figure className="g-explore" data-g="explore" data-sel={selected} ref={svgRef}>
|
|
271
|
+
{parts.length ? (
|
|
272
|
+
<div role="tablist" aria-label="选择一个部分">
|
|
273
|
+
{parts.map((part, index) => (
|
|
274
|
+
<button
|
|
275
|
+
key={part.id}
|
|
276
|
+
type="button"
|
|
277
|
+
role="tab"
|
|
278
|
+
aria-selected={selected === part.id}
|
|
279
|
+
tabIndex={selected === part.id ? 0 : -1}
|
|
280
|
+
data-sel={part.id}
|
|
281
|
+
onClick={() => setSelected(part.id)}
|
|
282
|
+
onKeyDown={(event) => {
|
|
283
|
+
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
const next = event.key === "ArrowRight" ? (index + 1) % parts.length : (index - 1 + parts.length) % parts.length;
|
|
286
|
+
setSelected(parts[next].id);
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{part.label}
|
|
290
|
+
</button>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
) : null}
|
|
294
|
+
{svg ? <div className="dx-wrap" dangerouslySetInnerHTML={{ __html: svg }} /> : null}
|
|
295
|
+
{typeof props.image === "string" ? (
|
|
296
|
+
<div className="fig-card">
|
|
297
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
298
|
+
<img src={props.image} alt={String(props.alt ?? props.caption ?? "")} />
|
|
299
|
+
</div>
|
|
300
|
+
) : null}
|
|
301
|
+
{typeof props.mermaid === "string" ? <pre className="code"><code>{props.mermaid}</code></pre> : null}
|
|
302
|
+
<div className="dx-exp" aria-live="polite">
|
|
303
|
+
{parts.map((part) => (
|
|
304
|
+
<p key={part.id} id={`${base}-${part.id}`} hidden={selected !== part.id}><Rich text={part.text} /></p>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
{typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
|
|
308
|
+
</figure>
|
|
309
|
+
);
|
|
310
|
+
return parts.length || svg ? <FullscreenFrame label="结构图">{body}</FullscreenFrame> : body;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function htmlHasScript(source: string) {
|
|
314
|
+
return /<script\b(?![^>]*\btype\s*=\s*["']application\/json["'])/iu.test(source);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isLightWidget(source: string) {
|
|
318
|
+
return /data-g="(?:quiz|calc|tabs|stepper|kvplay)"|class="[^"]*\b(?:g-steps|g-faq|g-quiz|g-tabs|g-play|g-calc)\b/iu.test(source);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function bindGardenWidgets(root: HTMLElement) {
|
|
322
|
+
root.querySelectorAll<HTMLElement>("[data-g=tabs]").forEach((widget) => {
|
|
323
|
+
const list = widget.querySelector<HTMLElement>(":scope > [role=tablist]");
|
|
324
|
+
const tabs = [...(list?.querySelectorAll<HTMLElement>(":scope > [role=tab]") ?? [])];
|
|
325
|
+
const select = (index: number, focus = false) => {
|
|
326
|
+
tabs.forEach((tab, tabIndex) => {
|
|
327
|
+
const on = index === tabIndex;
|
|
328
|
+
tab.setAttribute("aria-selected", String(on));
|
|
329
|
+
tab.tabIndex = on ? 0 : -1;
|
|
330
|
+
const panel = document.getElementById(tab.getAttribute("aria-controls") || "");
|
|
331
|
+
if (panel) panel.hidden = !on;
|
|
332
|
+
});
|
|
333
|
+
if (focus) tabs[index]?.focus();
|
|
334
|
+
};
|
|
335
|
+
tabs.forEach((tab, index) => {
|
|
336
|
+
tab.addEventListener("click", () => select(index));
|
|
337
|
+
tab.addEventListener("keydown", (event) => {
|
|
338
|
+
const count = tabs.length;
|
|
339
|
+
const next = event.key === "ArrowRight" ? (index + 1) % count
|
|
340
|
+
: event.key === "ArrowLeft" ? (index - 1 + count) % count
|
|
341
|
+
: event.key === "Home" ? 0
|
|
342
|
+
: event.key === "End" ? count - 1 : -1;
|
|
343
|
+
if (next < 0) return;
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
select(next, true);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
root.querySelectorAll<HTMLFormElement>("[data-g=quiz]").forEach((form) => {
|
|
351
|
+
const out = form.querySelector<HTMLElement>(".g-quiz-out");
|
|
352
|
+
const explain = form.querySelector<HTMLElement>(".g-quiz-ex");
|
|
353
|
+
form.addEventListener("submit", (event) => {
|
|
354
|
+
event.preventDefault();
|
|
355
|
+
if (!out) return;
|
|
356
|
+
const chosen = form.querySelector<HTMLInputElement>("input:checked");
|
|
357
|
+
if (!chosen) {
|
|
358
|
+
out.className = "g-quiz-out";
|
|
359
|
+
out.textContent = "先选一个答案。";
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const correct = chosen.value === form.dataset.answer;
|
|
363
|
+
out.className = `g-quiz-out ${correct ? "ok" : "no"}`;
|
|
364
|
+
out.textContent = correct ? "✓ 答对了" : "✗ 再想想";
|
|
365
|
+
if (explain) explain.hidden = !correct;
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
root.querySelectorAll<HTMLElement>("[data-g=stepper]").forEach((stepper) => {
|
|
370
|
+
const panels = [...stepper.querySelectorAll<HTMLElement>("[data-step]")];
|
|
371
|
+
const dots = [...stepper.querySelectorAll<HTMLElement>(".g-stepper-dots i")];
|
|
372
|
+
const count = stepper.querySelector(".g-stepper-n");
|
|
373
|
+
const title = stepper.querySelector(".g-stepper-t");
|
|
374
|
+
const prev = stepper.querySelector<HTMLButtonElement>("[data-prev]");
|
|
375
|
+
const next = stepper.querySelector<HTMLButtonElement>("[data-next]");
|
|
376
|
+
let index = 0;
|
|
377
|
+
const show = () => {
|
|
378
|
+
panels.forEach((panel, panelIndex) => { panel.hidden = panelIndex !== index; });
|
|
379
|
+
if (count) count.textContent = `${index + 1} / ${panels.length}`;
|
|
380
|
+
if (title) title.textContent = panels[index]?.dataset.title || "";
|
|
381
|
+
dots.forEach((dot, dotIndex) => dot.classList.toggle("on", dotIndex <= index));
|
|
382
|
+
if (prev) prev.disabled = index === 0;
|
|
383
|
+
if (next) next.disabled = index === panels.length - 1;
|
|
384
|
+
};
|
|
385
|
+
prev?.addEventListener("click", () => { if (index > 0) { index -= 1; show(); } });
|
|
386
|
+
next?.addEventListener("click", () => { if (index < panels.length - 1) { index += 1; show(); } });
|
|
387
|
+
show();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
root.querySelectorAll<HTMLElement>("[data-g=calc]").forEach((calc) => {
|
|
391
|
+
const raw = calc.querySelector("script[type='application/json'], script[type=\"application/json\"]")?.textContent;
|
|
392
|
+
if (!raw) return;
|
|
393
|
+
const spec = JSON.parse(raw) as {
|
|
394
|
+
formula: string;
|
|
395
|
+
vars: Record<string, { fixed?: number; values?: number[]; label?: string }>;
|
|
396
|
+
format?: string;
|
|
397
|
+
unit?: string;
|
|
398
|
+
show?: string;
|
|
399
|
+
};
|
|
400
|
+
const names = Object.keys(spec.vars);
|
|
401
|
+
const fn = new Function(...names, `return (${spec.formula})`) as (...args: number[]) => number;
|
|
402
|
+
const read = (key: string) => {
|
|
403
|
+
const variable = spec.vars[key];
|
|
404
|
+
if (variable?.fixed !== undefined) return Number(variable.fixed);
|
|
405
|
+
const field = calc.querySelector<HTMLInputElement | HTMLSelectElement>(`[data-k="${key}"]:checked`)
|
|
406
|
+
?? calc.querySelector<HTMLInputElement | HTMLSelectElement>(`[data-k="${key}"]`);
|
|
407
|
+
if (!field) return 0;
|
|
408
|
+
if (variable?.values && field instanceof HTMLInputElement && field.type === "range") {
|
|
409
|
+
const value = variable.values[Number(field.value)] ?? variable.values[0];
|
|
410
|
+
const output = field.parentElement?.querySelector("output");
|
|
411
|
+
if (output) output.textContent = String(value);
|
|
412
|
+
field.setAttribute("aria-valuetext", String(value));
|
|
413
|
+
return Number(value);
|
|
414
|
+
}
|
|
415
|
+
const value = Number(field.value);
|
|
416
|
+
const output = field.parentElement?.querySelector("output");
|
|
417
|
+
if (output && field instanceof HTMLInputElement && field.type === "range") output.textContent = field.value;
|
|
418
|
+
return value;
|
|
419
|
+
};
|
|
420
|
+
const run = () => {
|
|
421
|
+
const values = Object.fromEntries(names.map((key) => [key, read(key)]));
|
|
422
|
+
const result = fn(...names.map((key) => values[key]));
|
|
423
|
+
const out = calc.querySelector("[data-out]");
|
|
424
|
+
if (out) {
|
|
425
|
+
out.textContent = spec.format === "GB"
|
|
426
|
+
? `${(result / 2 ** 30).toFixed(2)} GB`
|
|
427
|
+
: `${Math.round(result * 1e4) / 1e4}${spec.unit ? ` ${spec.unit}` : ""}`;
|
|
428
|
+
}
|
|
429
|
+
const equation = calc.querySelector("[data-calc]");
|
|
430
|
+
if (equation && spec.show) {
|
|
431
|
+
const shown = spec.show.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key]));
|
|
432
|
+
equation.textContent = `${shown} = ${spec.format === "GB" ? `${Math.round(result).toLocaleString("en-US")} 字节` : String(Math.round(result * 1e4) / 1e4)}`;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
calc.addEventListener("input", run);
|
|
436
|
+
calc.addEventListener("change", run);
|
|
437
|
+
run();
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function StaticGardenHtml({ code }: { code: string }) {
|
|
442
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
443
|
+
useEffect(() => {
|
|
444
|
+
const host = ref.current;
|
|
445
|
+
if (!host) return;
|
|
446
|
+
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
|
|
447
|
+
root.innerHTML = code;
|
|
448
|
+
}, [code]);
|
|
449
|
+
return (
|
|
450
|
+
<div
|
|
451
|
+
className="g-html"
|
|
452
|
+
ref={ref}
|
|
453
|
+
suppressHydrationWarning
|
|
454
|
+
dangerouslySetInnerHTML={{ __html: `<template shadowrootmode="open">${code}</template>` }}
|
|
455
|
+
/>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function LightGardenHtml({ code }: { code: string }) {
|
|
460
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
const host = ref.current;
|
|
463
|
+
if (host) bindGardenWidgets(host);
|
|
464
|
+
}, [code]);
|
|
465
|
+
return <div className="g-widget" ref={ref} dangerouslySetInnerHTML={{ __html: code }} />;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function GardenHtml({ props, children }: { props: Record<string, unknown>; children?: string }) {
|
|
469
|
+
const code = typeof props.code === "string" ? props.code : children || "";
|
|
470
|
+
const title = String(props.title ?? "HTML");
|
|
471
|
+
if (!code && typeof props.src === "string") {
|
|
472
|
+
return <p className="g-embed-note">HTML 在 {props.src}。发布时由同名目录读入。</p>;
|
|
473
|
+
}
|
|
474
|
+
if (htmlHasScript(code)) {
|
|
475
|
+
return <SandboxFrame html={code} title={title} />;
|
|
476
|
+
}
|
|
477
|
+
if (isLightWidget(code)) {
|
|
478
|
+
const view = <LightGardenHtml code={code} />;
|
|
479
|
+
return /class="[^"]*\bg-play\b|data-g="calc"/iu.test(code) ? <FullscreenFrame label={title}>{view}</FullscreenFrame> : view;
|
|
480
|
+
}
|
|
481
|
+
return <FullscreenFrame label={title}><StaticGardenHtml code={code} /></FullscreenFrame>;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function GardenInteractive({ block }: { block: { id?: string | null; props: Record<string, unknown>; children?: string } }) {
|
|
485
|
+
const header = {
|
|
486
|
+
id: block.id || block.props.id || "interactive",
|
|
487
|
+
caption: block.props.caption ?? "",
|
|
488
|
+
intent: block.props.intent ?? "",
|
|
489
|
+
...(block.props.expect ? { expect: block.props.expect } : {}),
|
|
490
|
+
};
|
|
491
|
+
const source = `---\n${stringify(header)}---\n${block.children ?? ""}`;
|
|
492
|
+
const { payload, check } = compileUiBlock(source);
|
|
493
|
+
if (!payload) {
|
|
494
|
+
return (
|
|
495
|
+
<UiErrorCard
|
|
496
|
+
id={String(header.id)}
|
|
497
|
+
caption={String(header.caption)}
|
|
498
|
+
diagnostics={(check.diagnostics ?? []) as Diagnostic[]}
|
|
499
|
+
/>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
return (
|
|
503
|
+
<FullscreenFrame label={String(header.caption || "交互")}>
|
|
504
|
+
<div className="g-play">
|
|
505
|
+
<UiFigure payload={payload as UiPayload} />
|
|
506
|
+
</div>
|
|
507
|
+
</FullscreenFrame>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function GardenIdea({ text }: { text: string }) {
|
|
512
|
+
return (
|
|
513
|
+
<aside className="g-idea">
|
|
514
|
+
<p className="g-example-label">想法 · 发布前编译</p>
|
|
515
|
+
<p>{text}</p>
|
|
516
|
+
</aside>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function GardenLegacy({ suggestion }: { suggestion: string }) {
|
|
521
|
+
return <p className="g-embed-note">{suggestion}</p>;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function GardenUnknown({ name }: { name: string }) {
|
|
525
|
+
return <p className="g-embed-note">未知 Garden 块:{name}</p>;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function GardenMessage({ children }: { children: ReactNode }) {
|
|
529
|
+
return <p className="g-embed-note">{children}</p>;
|
|
530
|
+
}
|