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,350 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { acceptSandboxSizeMessage } from "./sandbox-message";
|
|
11
|
+
|
|
12
|
+
const COPY_ICON = (
|
|
13
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
14
|
+
<rect x="5.25" y="5.25" width="8.5" height="8.5" rx="1.5" />
|
|
15
|
+
<path d="M10.75 5.25V3.75a1.5 1.5 0 0 0-1.5-1.5h-5.5a1.5 1.5 0 0 0-1.5 1.5v5.5a1.5 1.5 0 0 0 1.5 1.5h1.5" />
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
const ENTER_ICON = (
|
|
19
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
20
|
+
<path d="M2.5 6V2.5H6M10 2.5h3.5V6M13.5 10v3.5H10M6 13.5H2.5V10" />
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
const EXIT_ICON = (
|
|
24
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
25
|
+
<path d="M6 2.5V6H2.5M13.5 6H10V2.5M10 13.5V10h3.5M2.5 10H6v3.5" />
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function writeClipboard(text: string) {
|
|
30
|
+
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
|
31
|
+
return new Promise<void>((resolve, reject) => {
|
|
32
|
+
const area = document.createElement("textarea");
|
|
33
|
+
area.value = text;
|
|
34
|
+
area.setAttribute("readonly", "");
|
|
35
|
+
area.style.position = "fixed";
|
|
36
|
+
area.style.opacity = "0";
|
|
37
|
+
document.body.appendChild(area);
|
|
38
|
+
area.select();
|
|
39
|
+
try {
|
|
40
|
+
if (document.execCommand("copy")) resolve();
|
|
41
|
+
else reject(new Error("copy"));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
reject(error);
|
|
44
|
+
} finally {
|
|
45
|
+
area.remove();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CopyButton({ getText, label = "复制代码" }: { getText: () => string; label?: string }) {
|
|
51
|
+
const [done, setDone] = useState(false);
|
|
52
|
+
const timer = useRef<number>(0);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className={`ux-btn ux-copy${done ? " ux-done" : ""}`}
|
|
59
|
+
aria-label={done ? "已复制" : label}
|
|
60
|
+
title={done ? "已复制" : label}
|
|
61
|
+
onClick={() => {
|
|
62
|
+
const text = getText();
|
|
63
|
+
writeClipboard(text).then(() => {
|
|
64
|
+
setDone(true);
|
|
65
|
+
window.clearTimeout(timer.current);
|
|
66
|
+
timer.current = window.setTimeout(() => setDone(false), 1500);
|
|
67
|
+
}).catch(() => setDone(false));
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{done ? "已复制" : COPY_ICON}
|
|
71
|
+
</button>
|
|
72
|
+
<span className="ux-live" aria-live="polite">{done ? "已复制到剪贴板" : ""}</span>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function CodeCopy({ children }: { children: ReactNode }) {
|
|
78
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
79
|
+
return (
|
|
80
|
+
<div className="ux-cb" ref={ref}>
|
|
81
|
+
{children}
|
|
82
|
+
<CopyButton getText={() => {
|
|
83
|
+
const pre = ref.current?.querySelector("pre");
|
|
84
|
+
const code = pre?.querySelector("code") ?? pre;
|
|
85
|
+
return (code?.textContent ?? "").replace(/\n$/u, "");
|
|
86
|
+
}} />
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function diffLines(source: string) {
|
|
92
|
+
return source.replace(/\n$/u, "").split("\n").map((line) => {
|
|
93
|
+
const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx";
|
|
94
|
+
const body = kind === "ctx" ? line.replace(/^ /u, "") : line.slice(1);
|
|
95
|
+
const mark = kind === "add" ? "+" : kind === "del" ? "−" : " ";
|
|
96
|
+
const spoken = kind === "add" ? "(新增)" : kind === "del" ? "(删除)" : "";
|
|
97
|
+
return { kind, body, mark, spoken };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function CodeDiff({
|
|
102
|
+
code,
|
|
103
|
+
diff,
|
|
104
|
+
next,
|
|
105
|
+
}: {
|
|
106
|
+
code: ReactNode;
|
|
107
|
+
diff: string;
|
|
108
|
+
next: string;
|
|
109
|
+
}) {
|
|
110
|
+
const [tab, setTab] = useState(0);
|
|
111
|
+
const base = useId();
|
|
112
|
+
const tabs = ["代码", "对比上一版"];
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="g-tabs g-code-group g-codediff" data-g="tabs">
|
|
116
|
+
<div role="tablist" aria-label="代码">
|
|
117
|
+
{tabs.map((label, index) => (
|
|
118
|
+
<button
|
|
119
|
+
key={label}
|
|
120
|
+
type="button"
|
|
121
|
+
role="tab"
|
|
122
|
+
id={`${base}-t${index}`}
|
|
123
|
+
aria-controls={`${base}-p${index}`}
|
|
124
|
+
aria-selected={tab === index}
|
|
125
|
+
tabIndex={tab === index ? 0 : -1}
|
|
126
|
+
onClick={() => setTab(index)}
|
|
127
|
+
onKeyDown={(event) => {
|
|
128
|
+
const nextIndex = event.key === "ArrowRight" || event.key === "ArrowLeft" ? (index + 1) % 2 : -1;
|
|
129
|
+
if (nextIndex < 0) return;
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
setTab(nextIndex);
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{label}
|
|
135
|
+
</button>
|
|
136
|
+
))}
|
|
137
|
+
<CopyButton getText={() => {
|
|
138
|
+
if (tab === 1) return next;
|
|
139
|
+
const root = document.getElementById(`${base}-p0`);
|
|
140
|
+
return (root?.innerText ?? "").replace(/\n$/u, "");
|
|
141
|
+
}} />
|
|
142
|
+
</div>
|
|
143
|
+
<div role="tabpanel" id={`${base}-p0`} aria-labelledby={`${base}-t0`} hidden={tab !== 0}>
|
|
144
|
+
{code}
|
|
145
|
+
</div>
|
|
146
|
+
<div role="tabpanel" id={`${base}-p1`} aria-labelledby={`${base}-t1`} hidden={tab !== 1}>
|
|
147
|
+
<pre className="code g-diff">
|
|
148
|
+
<code>
|
|
149
|
+
{diffLines(diff).map((line, index) => (
|
|
150
|
+
<span className={`d-${line.kind}`} key={index}>
|
|
151
|
+
<span className="d-m" aria-hidden="true">{line.mark}</span>
|
|
152
|
+
{line.body}
|
|
153
|
+
{line.spoken ? <span className="vh">{line.spoken}</span> : null}
|
|
154
|
+
</span>
|
|
155
|
+
))}
|
|
156
|
+
</code>
|
|
157
|
+
</pre>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function fullscreenElement() {
|
|
164
|
+
return document.fullscreenElement || (document as Document & { webkitFullscreenElement?: Element }).webkitFullscreenElement || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function FullscreenFrame({ children, label = "演示" }: { children: ReactNode; label?: string }) {
|
|
168
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
169
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
170
|
+
const restoreRef = useRef<HTMLElement | null>(null);
|
|
171
|
+
const [overlay, setOverlay] = useState(false);
|
|
172
|
+
const [active, setActive] = useState(false);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const onChange = () => {
|
|
176
|
+
const on = fullscreenElement() === ref.current;
|
|
177
|
+
setActive(on || overlay);
|
|
178
|
+
if (!on && !overlay && restoreRef.current) {
|
|
179
|
+
restoreRef.current.focus();
|
|
180
|
+
restoreRef.current = null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
document.addEventListener("fullscreenchange", onChange);
|
|
184
|
+
document.addEventListener("webkitfullscreenchange", onChange);
|
|
185
|
+
return () => {
|
|
186
|
+
document.removeEventListener("fullscreenchange", onChange);
|
|
187
|
+
document.removeEventListener("webkitfullscreenchange", onChange);
|
|
188
|
+
};
|
|
189
|
+
}, [overlay]);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!overlay) return undefined;
|
|
193
|
+
const root = ref.current;
|
|
194
|
+
document.documentElement.classList.add("ux-lock");
|
|
195
|
+
const trap = (event: KeyboardEvent) => {
|
|
196
|
+
if (event.key === "Escape") {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
setOverlay(false);
|
|
199
|
+
setActive(false);
|
|
200
|
+
document.documentElement.classList.remove("ux-lock");
|
|
201
|
+
restoreRef.current?.focus();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (event.key !== "Tab" || !root) return;
|
|
205
|
+
const items = [...root.querySelectorAll<HTMLElement>("button, a[href], input, select, textarea, iframe, [tabindex]:not([tabindex='-1'])")]
|
|
206
|
+
.filter((item) => !item.hasAttribute("disabled") && item.getClientRects().length > 0);
|
|
207
|
+
if (!items.length) return;
|
|
208
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
209
|
+
const last = items.length - 1;
|
|
210
|
+
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === last)) {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
items[index < 0 ? 0 : event.shiftKey ? last : 0].focus();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const pull = (event: FocusEvent) => {
|
|
216
|
+
if (root && !root.contains(event.target as Node)) buttonRef.current?.focus();
|
|
217
|
+
};
|
|
218
|
+
document.addEventListener("keydown", trap, true);
|
|
219
|
+
document.addEventListener("focusin", pull);
|
|
220
|
+
buttonRef.current?.focus();
|
|
221
|
+
return () => {
|
|
222
|
+
document.documentElement.classList.remove("ux-lock");
|
|
223
|
+
document.removeEventListener("keydown", trap, true);
|
|
224
|
+
document.removeEventListener("focusin", pull);
|
|
225
|
+
};
|
|
226
|
+
}, [overlay]);
|
|
227
|
+
|
|
228
|
+
const open = () => {
|
|
229
|
+
const node = ref.current;
|
|
230
|
+
if (!node) return;
|
|
231
|
+
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
232
|
+
const request = node.requestFullscreen || (node as HTMLElement & { webkitRequestFullscreen?: () => Promise<void> }).webkitRequestFullscreen;
|
|
233
|
+
if (request && (document.fullscreenEnabled || (document as Document & { webkitFullscreenEnabled?: boolean }).webkitFullscreenEnabled)) {
|
|
234
|
+
void request.call(node);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
setOverlay(true);
|
|
238
|
+
setActive(true);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const close = () => {
|
|
242
|
+
if (fullscreenElement()) {
|
|
243
|
+
void (document.exitFullscreen?.() || (document as Document & { webkitExitFullscreen?: () => Promise<void> }).webkitExitFullscreen?.());
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
setOverlay(false);
|
|
247
|
+
setActive(false);
|
|
248
|
+
restoreRef.current?.focus();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div ref={ref} className={`ux-fs${overlay ? " ux-fs-on" : ""}`} role={overlay ? "dialog" : undefined} aria-modal={overlay ? true : undefined} aria-label={overlay ? label : undefined}>
|
|
253
|
+
<div className="ux-fsbar">
|
|
254
|
+
<button
|
|
255
|
+
ref={buttonRef}
|
|
256
|
+
type="button"
|
|
257
|
+
className="ux-btn ux-fsb"
|
|
258
|
+
aria-label={active ? "退出全屏" : "全屏"}
|
|
259
|
+
title={active ? "退出全屏" : "全屏"}
|
|
260
|
+
onClick={() => (active ? close() : open())}
|
|
261
|
+
>
|
|
262
|
+
{active ? EXIT_ICON : ENTER_ICON}
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
{children}
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const FRAME_BOOT = `<style>
|
|
271
|
+
html,body{background:transparent!important;height:auto!important;min-height:0!important;margin:0;max-width:100%}
|
|
272
|
+
body>*{max-width:100%;box-sizing:border-box}
|
|
273
|
+
svg,img,canvas,video,table{max-width:100%;height:auto}
|
|
274
|
+
#r>style:first-child+*,#r>:first-child:not(style),body>main:first-child>:first-child:not(style){padding-right:44px;box-sizing:border-box}
|
|
275
|
+
</style><script>(function(){
|
|
276
|
+
var last=0,lock=0;
|
|
277
|
+
function fit(){var b=document.body,root=document.documentElement;if(!b)return 1;b.style.zoom="";b.style.transform="";b.style.transformOrigin="";b.style.height="";root.style.height="";var cw=root.clientWidth||b.clientWidth;if(!(cw>0))return 1;var sw=Math.max(root.scrollWidth,b.scrollWidth);if(!(sw>cw+1))return 1;var z=cw/sw;b.style.zoom=String(z);var again=root.scrollWidth;if(again>cw+1&&again<sw-1){z=z*cw/again;b.style.zoom=String(z);again=root.scrollWidth}if(again>cw+1){b.style.zoom="";var h=Math.max(root.scrollHeight,b.scrollHeight);b.style.transformOrigin="0 0";b.style.transform="scale("+z+")";b.style.height=Math.ceil(h*z)+"px";root.style.height=Math.ceil(h*z)+"px"}return z}
|
|
278
|
+
function m(force){if(lock)return;var b=document.body;if(!b)return;lock=1;fit();var h=0,cs=getComputedStyle(b);[].forEach.call(b.children,function(e){if(e.tagName==="SCRIPT"||e.tagName==="STYLE")return;var r=e.getBoundingClientRect(),st=getComputedStyle(e);h=Math.max(h,r.bottom+parseFloat(st.marginBottom||0))});h=Math.ceil(h+parseFloat(cs.paddingBottom||0)+parseFloat(cs.marginBottom||0)+2);if(h>0&&(force||h!==last)){last=h;parent.postMessage({type:"ux:size",height:h,fid:"__UX_FID__"},"*")}lock=0}
|
|
279
|
+
var q=0,pending=0;function s(force){if(force)pending=1;if(q)return;q=1;requestAnimationFrame(function(){q=0;var run=pending;pending=0;m(run)})}
|
|
280
|
+
function watch(){[].forEach.call(document.images||[],function(img){if(img.dataset.uxSized)return;img.dataset.uxSized="1";img.addEventListener("load",s);img.addEventListener("error",s)})}
|
|
281
|
+
var started=0;function boot(){watch();s();if(started||!window.ResizeObserver)return;started=1;var ro=new ResizeObserver(s);ro.observe(document.documentElement);if(document.body)ro.observe(document.body)}
|
|
282
|
+
addEventListener("DOMContentLoaded",boot);addEventListener("load",boot);addEventListener("resize",s);
|
|
283
|
+
var acked=0;
|
|
284
|
+
addEventListener("message",function(e){var d=e.data;if(!d)return;if(d.type==="ux:ack"&&d.fid==="__UX_FID__"){acked=1;return}if(d.type!=="ux:measure"||(d.fid&&d.fid!=="__UX_FID__"))return;last=0;m(1)});
|
|
285
|
+
var tries=0,tick=setInterval(function(){if(acked||++tries>30){clearInterval(tick);return}last=0;m(1)},200);
|
|
286
|
+
setTimeout(function(){last=0;m(1)},0);
|
|
287
|
+
})()<\/script>`;
|
|
288
|
+
|
|
289
|
+
export function frameSrcdoc(html: string, title: string, fid = "ux") {
|
|
290
|
+
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:;">`;
|
|
291
|
+
const safe = fid.replace(/[^A-Za-z0-9_-]/g, "") || "ux";
|
|
292
|
+
const boot = FRAME_BOOT.replaceAll("__UX_FID__", safe);
|
|
293
|
+
if (/<head[^>]*>/iu.test(html)) return html.replace(/<head[^>]*>/iu, (head) => `${head}${csp}${boot}`);
|
|
294
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title>${csp}${boot}</head><body>${html}</body></html>`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function SandboxFrame({ html, title }: { html: string; title: string }) {
|
|
298
|
+
const ref = useRef<HTMLIFrameElement>(null);
|
|
299
|
+
const [height, setHeight] = useState(80);
|
|
300
|
+
const fid = useId().replace(/[^A-Za-z0-9_-]/g, "") || "ux";
|
|
301
|
+
const srcDoc = frameSrcdoc(html, title, fid);
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
const frame = ref.current;
|
|
305
|
+
let settled = false;
|
|
306
|
+
let tries = 0;
|
|
307
|
+
const ask = () => frame?.contentWindow?.postMessage({ type: "ux:measure", fid }, "*");
|
|
308
|
+
const onMessage = (event: MessageEvent) => {
|
|
309
|
+
if (!frame) return;
|
|
310
|
+
const data = event.data;
|
|
311
|
+
if (!acceptSandboxSizeMessage(data, fid, event.source, frame.contentWindow)) return;
|
|
312
|
+
const next = Number(data.height);
|
|
313
|
+
if (!Number.isFinite(next) || next < 48) return;
|
|
314
|
+
if (frame.closest(".ux-fs:fullscreen, .ux-fs-on")) return;
|
|
315
|
+
settled = true;
|
|
316
|
+
frame.contentWindow?.postMessage({ type: "ux:ack", fid }, "*");
|
|
317
|
+
setHeight(Math.max(60, Math.min(1600, next)));
|
|
318
|
+
};
|
|
319
|
+
window.addEventListener("message", onMessage);
|
|
320
|
+
frame?.addEventListener("load", ask);
|
|
321
|
+
ask();
|
|
322
|
+
const timer = window.setInterval(() => {
|
|
323
|
+
if (settled || tries++ > 30) {
|
|
324
|
+
window.clearInterval(timer);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
ask();
|
|
328
|
+
}, 200);
|
|
329
|
+
return () => {
|
|
330
|
+
window.clearInterval(timer);
|
|
331
|
+
window.removeEventListener("message", onMessage);
|
|
332
|
+
frame?.removeEventListener("load", ask);
|
|
333
|
+
};
|
|
334
|
+
}, [fid]);
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<FullscreenFrame label={title}>
|
|
338
|
+
<div className="g-sandbox">
|
|
339
|
+
<iframe
|
|
340
|
+
ref={ref}
|
|
341
|
+
title={title}
|
|
342
|
+
sandbox="allow-scripts"
|
|
343
|
+
referrerPolicy="no-referrer"
|
|
344
|
+
srcDoc={srcDoc}
|
|
345
|
+
style={{ height, overflow: "hidden", background: "transparent" }}
|
|
346
|
+
/>
|
|
347
|
+
</div>
|
|
348
|
+
</FullscreenFrame>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SandboxSizeData = {
|
|
2
|
+
type?: string;
|
|
3
|
+
fid?: string;
|
|
4
|
+
height?: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** A resize is from this frame only when both the window and the frame id match. */
|
|
8
|
+
export function acceptSandboxSizeMessage(
|
|
9
|
+
data: SandboxSizeData | null | undefined,
|
|
10
|
+
fid: string,
|
|
11
|
+
source: unknown,
|
|
12
|
+
contentWindow: unknown,
|
|
13
|
+
): boolean {
|
|
14
|
+
if (!data || (data.type !== "ux:size" && data.type !== "garden:resize")) return false;
|
|
15
|
+
return contentWindow != null && source === contentWindow && data.fid === fid;
|
|
16
|
+
}
|
package/app/garden-modules.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ declare module "@garden/generated-content" {
|
|
|
34
34
|
columnCoverAlt?: string;
|
|
35
35
|
order?: number;
|
|
36
36
|
cover?: string;
|
|
37
|
+
original?: string;
|
|
37
38
|
mediaType?: "image" | "audio" | "video" | "link" | "text";
|
|
38
39
|
media?: string | MediaItem[];
|
|
39
40
|
mediaAlt?: string;
|
|
@@ -46,6 +47,7 @@ declare module "@garden/generated-content" {
|
|
|
46
47
|
export const posts: ContentEntry[];
|
|
47
48
|
export const columns: ContentEntry[];
|
|
48
49
|
export const thoughts: ContentEntry[];
|
|
50
|
+
export const a2WordCounts: Record<string, number>;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
declare module "@garden/site-config" {
|
package/app/globals.css
CHANGED
|
@@ -3761,27 +3761,32 @@ button {
|
|
|
3761
3761
|
.toc-mobile-trigger {
|
|
3762
3762
|
position: fixed;
|
|
3763
3763
|
right: calc(16px + env(safe-area-inset-right));
|
|
3764
|
-
bottom: calc(
|
|
3764
|
+
bottom: calc(16px + env(safe-area-inset-bottom));
|
|
3765
3765
|
z-index: 70;
|
|
3766
|
-
min-height:
|
|
3767
|
-
padding: 0 15px;
|
|
3766
|
+
min-height: 44px;
|
|
3767
|
+
padding: 0 18px 0 15px;
|
|
3768
3768
|
display: inline-flex;
|
|
3769
3769
|
align-items: center;
|
|
3770
3770
|
gap: 8px;
|
|
3771
|
-
border: 1px solid
|
|
3771
|
+
border: 1px solid #d9d5cc;
|
|
3772
3772
|
border-radius: 999px;
|
|
3773
|
-
background:
|
|
3774
|
-
box-shadow: 0 10px
|
|
3775
|
-
color: var(--
|
|
3776
|
-
font
|
|
3777
|
-
|
|
3778
|
-
letter-spacing: 0.05em;
|
|
3779
|
-
backdrop-filter: blur(12px);
|
|
3780
|
-
-webkit-backdrop-filter: blur(12px);
|
|
3773
|
+
background: rgb(255 255 255 / 94%);
|
|
3774
|
+
box-shadow: 0 2px 10px rgb(27 26 24 / 10%);
|
|
3775
|
+
color: var(--ink);
|
|
3776
|
+
font: 600 14px/1 var(--sans);
|
|
3777
|
+
letter-spacing: 0;
|
|
3781
3778
|
touch-action: manipulation;
|
|
3779
|
+
cursor: pointer;
|
|
3782
3780
|
transition: opacity 160ms ease, transform 160ms ease, visibility 0s;
|
|
3783
3781
|
}
|
|
3784
3782
|
|
|
3783
|
+
:root[data-theme="dark"] .toc-mobile-trigger,
|
|
3784
|
+
html[data-theme="dark"] .toc-mobile-trigger {
|
|
3785
|
+
background: rgb(30 29 26 / 94%);
|
|
3786
|
+
border-color: var(--rule-strong);
|
|
3787
|
+
color: var(--ink);
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3785
3790
|
.toc-mobile-trigger:hover,
|
|
3786
3791
|
.toc-mobile-trigger:focus-visible {
|
|
3787
3792
|
transform: translateY(-2px);
|
package/app/layout.tsx
CHANGED
|
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
|
|
2
2
|
import { Geist_Mono } from "next/font/google";
|
|
3
3
|
import "katex/dist/katex.min.css";
|
|
4
4
|
import "./globals.css";
|
|
5
|
+
import "./styles/tokens.css";
|
|
6
|
+
import "./styles/a2.css";
|
|
7
|
+
import "./styles/shell.css";
|
|
5
8
|
import { SiteChrome } from "./components/SiteChrome";
|
|
6
9
|
import {
|
|
7
10
|
getSiteUrl,
|
|
@@ -82,6 +85,12 @@ export default function RootLayout({
|
|
|
82
85
|
return (
|
|
83
86
|
<html lang="zh-CN" suppressHydrationWarning>
|
|
84
87
|
<head>
|
|
88
|
+
<script
|
|
89
|
+
dangerouslySetInnerHTML={{
|
|
90
|
+
__html:
|
|
91
|
+
'(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||(!t&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light";}catch(e){}})();',
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
85
94
|
{mentionEndpoint && <link rel="webmention" href={mentionEndpoint} />}
|
|
86
95
|
{analyticsDomain && (
|
|
87
96
|
<script
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const shanghaiOffsetMs = 8 * 60 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
function shanghaiDate(value: string) {
|
|
4
|
+
const date = new Date(value);
|
|
5
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
6
|
+
return new Date(date.getTime() + shanghaiOffsetMs);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Calendar date in Asia/Shanghai, the zone the demo uses for 「2026 年 9 月 18 日」. */
|
|
10
|
+
export function demoDateLabel(value: string) {
|
|
11
|
+
const date = shanghaiDate(value);
|
|
12
|
+
if (!date) return value;
|
|
13
|
+
return `${date.getUTCFullYear()} 年 ${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function demoDateTime(value: string) {
|
|
17
|
+
const date = shanghaiDate(value);
|
|
18
|
+
if (!date) return value.slice(0, 10);
|
|
19
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
20
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
21
|
+
return `${date.getUTCFullYear()}-${month}-${day}`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
|
|
5
|
+
const dir = path.join(process.cwd(), "parity", "articles");
|
|
6
|
+
|
|
7
|
+
const originals: Record<string, string> = {
|
|
8
|
+
"ai-agent-react-vs-workflow": "https://hedon.top/blog/ai-agent/ai-agent-react-vs-workflow/",
|
|
9
|
+
"rust-action-spinlock": "https://hedon.top/blog/rust-action-spinlock/",
|
|
10
|
+
"claude-code-source": "https://hedon.top/blog/claude-code-source/",
|
|
11
|
+
"go-gc-tri-color-marking": "https://hedon.top/blog/go/go-gc-tri-color-marking/",
|
|
12
|
+
"kv-cache-interactive": "https://hedon.top/blog/llm-kv-cache/",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type A2Article = {
|
|
16
|
+
slug: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
date: string;
|
|
20
|
+
topic: string;
|
|
21
|
+
cover: string;
|
|
22
|
+
original: string;
|
|
23
|
+
content: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function a2Slugs() {
|
|
27
|
+
if (!fs.existsSync(dir)) return [];
|
|
28
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3)).sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readA2Article(slug: string): A2Article | null {
|
|
32
|
+
const file = path.join(dir, `${slug}.md`);
|
|
33
|
+
if (!fs.existsSync(file)) return null;
|
|
34
|
+
const parsed = matter(fs.readFileSync(file, "utf8"));
|
|
35
|
+
const data = parsed.data ?? {};
|
|
36
|
+
const cover = typeof data.cover === "string" ? data.cover : "";
|
|
37
|
+
const date = data.date instanceof Date && !Number.isNaN(data.date.getTime())
|
|
38
|
+
? data.date.toISOString()
|
|
39
|
+
: typeof data.date === "string" ? data.date : "";
|
|
40
|
+
return {
|
|
41
|
+
slug,
|
|
42
|
+
title: String(data.title ?? slug),
|
|
43
|
+
description: String(data.description ?? ""),
|
|
44
|
+
date,
|
|
45
|
+
topic: String(data.topic ?? ""),
|
|
46
|
+
cover: cover && !/^https?:/iu.test(cover) ? `https://hedon.top/media/covers/${cover}` : cover,
|
|
47
|
+
original: originals[slug] ?? "",
|
|
48
|
+
content: parsed.content,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/app/lib/content.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "@garden/generated-content";
|
|
9
9
|
import GithubSlugger from "github-slugger";
|
|
10
10
|
import { canonicalSiteDate } from "../../scripts/site-date.mjs";
|
|
11
|
+
import { demoDateLabel } from "./a2-meta";
|
|
11
12
|
import { contentToSearchText } from "../../ui/core/transform.mjs";
|
|
12
13
|
import { toString } from "mdast-util-to-string";
|
|
13
14
|
import remarkParse from "remark-parse";
|
|
@@ -26,6 +27,7 @@ export const columnHref = (entry: Pick<ContentEntry, "column" | "path">) =>
|
|
|
26
27
|
`/columns/${entry.column}/${columnEntryPath(entry)}`;
|
|
27
28
|
|
|
28
29
|
export const formatDate = (value: string, withTime = false) => {
|
|
30
|
+
if (!withTime) return demoDateLabel(value);
|
|
29
31
|
const canonical = canonicalSiteDate(value) ?? value;
|
|
30
32
|
const date = new Date(canonical);
|
|
31
33
|
if (Number.isNaN(date.getTime())) return value;
|
|
@@ -34,26 +36,70 @@ export const formatDate = (value: string, withTime = false) => {
|
|
|
34
36
|
year: "numeric",
|
|
35
37
|
month: "2-digit",
|
|
36
38
|
day: "2-digit",
|
|
37
|
-
|
|
39
|
+
hour: "2-digit",
|
|
40
|
+
minute: "2-digit",
|
|
38
41
|
}).format(date);
|
|
39
42
|
};
|
|
40
43
|
|
|
44
|
+
const NUMBERED_ARTICLE = /^#{2,3}[ \t]+\d/m;
|
|
45
|
+
|
|
46
|
+
/** An article that already writes its own heading numbers is not auto-numbered. */
|
|
47
|
+
export const articleUsesHeadingNumbers = (markdown: string) => NUMBERED_ARTICLE.test(markdown);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prototype render: an article with no `##` treats `###` as the top section.
|
|
51
|
+
* Display level is `depth - base + 2`, and anything past h3 stays an h4.
|
|
52
|
+
*/
|
|
53
|
+
export const articleHeadingBase = (markdown: string) => (/^##[ \t]/m.test(markdown) ? 2 : 3);
|
|
54
|
+
|
|
55
|
+
export const displayHeadingLevel = (depth: number, base: number) => {
|
|
56
|
+
if (depth < 2 || depth > 4) return depth;
|
|
57
|
+
const level = depth - base + 2;
|
|
58
|
+
return level > 3 ? 4 : level;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Pull a leading `2.1` into the section marker and leave the heading sentence. */
|
|
62
|
+
export const splitHeadingNumber = (text: string) => {
|
|
63
|
+
const match = /^(\d+(?:\.\d+)*)\.?[ \t]+([\s\S]+)$/u.exec(text.trim());
|
|
64
|
+
return match ? { no: match[1], text: match[2] } : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
41
67
|
/**
|
|
42
68
|
* Parses the same Markdown heading text that ReactMarkdown sends to
|
|
43
69
|
* rehype-slug. Every heading depth advances the slugger so headings outside
|
|
44
70
|
* the H2-H5 TOC range cannot shift the IDs shown in the directory.
|
|
71
|
+
* `no` follows the prototype: keep an author-written number, otherwise
|
|
72
|
+
* auto-number h2/h3 only when the article has no numbered headings.
|
|
45
73
|
*/
|
|
46
74
|
export const extractHeadings = (markdown: string) => {
|
|
47
75
|
const slugger = new GithubSlugger();
|
|
48
|
-
const
|
|
76
|
+
const numbered = articleUsesHeadingNumbers(markdown);
|
|
77
|
+
const base = articleHeadingBase(markdown);
|
|
78
|
+
const headings: Array<{ depth: number; text: string; id: string; no?: string }> = [];
|
|
49
79
|
const tree = unified().use(remarkParse).use(markdownPlugins).parse(markdown);
|
|
80
|
+
let h2 = 0;
|
|
81
|
+
let h3 = 0;
|
|
50
82
|
|
|
51
83
|
visit(tree, "heading", (node) => {
|
|
52
|
-
const
|
|
53
|
-
const id = slugger.slug(
|
|
54
|
-
if (
|
|
55
|
-
|
|
84
|
+
const raw = toString(node).trim();
|
|
85
|
+
const id = slugger.slug(raw);
|
|
86
|
+
if (!(raw && node.depth >= 2 && node.depth <= 5)) return;
|
|
87
|
+
const level = displayHeadingLevel(node.depth, base);
|
|
88
|
+
let text = raw;
|
|
89
|
+
let no: string | undefined;
|
|
90
|
+
if (level === 2 || level === 3) {
|
|
91
|
+
const split = splitHeadingNumber(raw);
|
|
92
|
+
if (split) {
|
|
93
|
+
no = split.no;
|
|
94
|
+
text = split.text;
|
|
95
|
+
if (level === 2) { h2 += 1; h3 = 0; }
|
|
96
|
+
else h3 += 1;
|
|
97
|
+
} else if (!numbered) {
|
|
98
|
+
if (level === 2) { h2 += 1; h3 = 0; no = String(h2); }
|
|
99
|
+
else { h3 += 1; no = h2 ? `${h2}.${h3}` : String(h3); }
|
|
100
|
+
}
|
|
56
101
|
}
|
|
102
|
+
headings.push({ depth: level, text, id, ...(no ? { no } : {}) });
|
|
57
103
|
});
|
|
58
104
|
|
|
59
105
|
return headings;
|
|
@@ -3,6 +3,7 @@ import remarkCjkFriendly from "remark-cjk-friendly";
|
|
|
3
3
|
import remarkBreaks from "remark-breaks";
|
|
4
4
|
import remarkMath from "remark-math";
|
|
5
5
|
import type { PluggableList } from "unified";
|
|
6
|
+
import { remarkGarden } from "./remark-garden.mjs";
|
|
6
7
|
|
|
7
8
|
// Keep article rendering and heading extraction on the same Markdown syntax.
|
|
8
9
|
export const markdownPlugins: PluggableList = [
|
|
@@ -10,4 +11,5 @@ export const markdownPlugins: PluggableList = [
|
|
|
10
11
|
remarkCjkFriendly,
|
|
11
12
|
remarkBreaks,
|
|
12
13
|
remarkMath,
|
|
14
|
+
remarkGarden,
|
|
13
15
|
];
|