hd-garden 0.3.13 → 0.4.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/app/a2/[slug]/page.tsx +61 -0
- package/app/a2/page.tsx +27 -0
- package/app/blog/[...path]/page.tsx +33 -22
- package/app/blog/page.tsx +6 -7
- package/app/columns/[column]/[...path]/page.tsx +33 -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 +48 -5
- 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
|
@@ -4,17 +4,38 @@ import { useEffect, useId, useState } from "react";
|
|
|
4
4
|
|
|
5
5
|
let mermaidRenderQueue: Promise<unknown> = Promise.resolve();
|
|
6
6
|
|
|
7
|
+
function token(name: string) {
|
|
8
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
function renderMermaid(source: string, id: string) {
|
|
8
12
|
const task = mermaidRenderQueue.then(async () => {
|
|
9
13
|
const mermaid = (await import("mermaid")).default;
|
|
14
|
+
const dark = document.documentElement.getAttribute("data-theme") === "dark";
|
|
15
|
+
const prepared = source
|
|
16
|
+
.replace(/^\s*classDef .*$/gm, "")
|
|
17
|
+
.replace(/^\s*style .*$/gm, "");
|
|
10
18
|
mermaid.initialize({
|
|
11
19
|
startOnLoad: false,
|
|
12
|
-
theme: "neutral",
|
|
13
|
-
fontFamily: "var(--font-mono)",
|
|
14
20
|
securityLevel: "strict",
|
|
15
21
|
suppressErrorRendering: true,
|
|
22
|
+
theme: "base",
|
|
23
|
+
fontFamily: token("--sans") || "-apple-system, PingFang SC, sans-serif",
|
|
24
|
+
flowchart: { useMaxWidth: true, htmlLabels: false },
|
|
25
|
+
themeVariables: {
|
|
26
|
+
darkMode: dark,
|
|
27
|
+
background: token("--paper"),
|
|
28
|
+
primaryColor: token("--card"),
|
|
29
|
+
primaryBorderColor: token("--rule-strong"),
|
|
30
|
+
primaryTextColor: token("--ink"),
|
|
31
|
+
lineColor: token("--muted"),
|
|
32
|
+
secondaryColor: token("--well"),
|
|
33
|
+
tertiaryColor: token("--well"),
|
|
34
|
+
fontSize: "14px",
|
|
35
|
+
edgeLabelBackground: token("--paper"),
|
|
36
|
+
},
|
|
16
37
|
});
|
|
17
|
-
return mermaid.render(id,
|
|
38
|
+
return mermaid.render(id, prepared);
|
|
18
39
|
});
|
|
19
40
|
mermaidRenderQueue = task.then(
|
|
20
41
|
() => undefined,
|
|
@@ -25,93 +46,45 @@ function renderMermaid(source: string, id: string) {
|
|
|
25
46
|
|
|
26
47
|
export function MermaidDiagram({ source }: { source: string }) {
|
|
27
48
|
const reactId = useId();
|
|
28
|
-
const [view, setView] = useState<"render" | "code">("render");
|
|
29
49
|
const [svg, setSvg] = useState("");
|
|
30
|
-
const [
|
|
31
|
-
|
|
50
|
+
const [theme, setTheme] = useState("light");
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const sync = () => setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
|
|
54
|
+
sync();
|
|
55
|
+
const observer = new MutationObserver(sync);
|
|
56
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
|
57
|
+
return () => observer.disconnect();
|
|
58
|
+
}, []);
|
|
32
59
|
|
|
33
60
|
useEffect(() => {
|
|
34
61
|
let active = true;
|
|
35
62
|
const draw = async () => {
|
|
36
|
-
|
|
63
|
+
setSvg("");
|
|
37
64
|
try {
|
|
38
|
-
const id = `mermaid-${reactId.replaceAll(":", "")}`;
|
|
65
|
+
const id = `mermaid-${reactId.replaceAll(":", "")}-${theme}`;
|
|
39
66
|
const result = await renderMermaid(source, id);
|
|
40
67
|
if (active) setSvg(result.svg);
|
|
41
68
|
} catch {
|
|
42
|
-
if (active)
|
|
69
|
+
if (active) setSvg("");
|
|
43
70
|
}
|
|
44
71
|
};
|
|
45
72
|
void draw();
|
|
46
73
|
return () => {
|
|
47
74
|
active = false;
|
|
48
75
|
};
|
|
49
|
-
}, [reactId, source]);
|
|
76
|
+
}, [reactId, source, theme]);
|
|
50
77
|
|
|
51
78
|
return (
|
|
52
79
|
<figure
|
|
53
|
-
className=
|
|
54
|
-
data-
|
|
80
|
+
className={`g-mermaid mermaid-block language-mermaid${svg ? " ok" : ""}`}
|
|
81
|
+
data-g="mermaid"
|
|
82
|
+
data-mermaid-theme="a2"
|
|
55
83
|
>
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
role="tablist"
|
|
61
|
-
aria-label="切换 Mermaid 显示方式"
|
|
62
|
-
>
|
|
63
|
-
<button
|
|
64
|
-
type="button"
|
|
65
|
-
role="tab"
|
|
66
|
-
aria-selected={view === "render"}
|
|
67
|
-
aria-controls={panelId}
|
|
68
|
-
tabIndex={view === "render" ? 0 : -1}
|
|
69
|
-
onClick={() => setView("render")}
|
|
70
|
-
>
|
|
71
|
-
图表
|
|
72
|
-
</button>
|
|
73
|
-
<button
|
|
74
|
-
type="button"
|
|
75
|
-
role="tab"
|
|
76
|
-
aria-selected={view === "code"}
|
|
77
|
-
aria-controls={panelId}
|
|
78
|
-
tabIndex={view === "code" ? 0 : -1}
|
|
79
|
-
onClick={() => setView("code")}
|
|
80
|
-
>
|
|
81
|
-
Code
|
|
82
|
-
</button>
|
|
83
|
-
</span>
|
|
84
|
-
</figcaption>
|
|
85
|
-
{view === "render" ? (
|
|
86
|
-
<div
|
|
87
|
-
className="mermaid-canvas"
|
|
88
|
-
id={panelId}
|
|
89
|
-
role="tabpanel"
|
|
90
|
-
aria-live="polite"
|
|
91
|
-
>
|
|
92
|
-
{svg ? (
|
|
93
|
-
<div
|
|
94
|
-
className="mermaid-svg"
|
|
95
|
-
dangerouslySetInnerHTML={{ __html: svg }}
|
|
96
|
-
/>
|
|
97
|
-
) : null}
|
|
98
|
-
{!svg && !error ? (
|
|
99
|
-
<span className="mermaid-loading">正在绘制图表…</span>
|
|
100
|
-
) : null}
|
|
101
|
-
{error ? (
|
|
102
|
-
<span className="mermaid-error">图表渲染失败,请检查语法。</span>
|
|
103
|
-
) : null}
|
|
104
|
-
</div>
|
|
105
|
-
) : (
|
|
106
|
-
<pre
|
|
107
|
-
className="mermaid-code"
|
|
108
|
-
id={panelId}
|
|
109
|
-
role="tabpanel"
|
|
110
|
-
data-language="MERMAID"
|
|
111
|
-
>
|
|
112
|
-
<code>{source}</code>
|
|
113
|
-
</pre>
|
|
114
|
-
)}
|
|
84
|
+
{svg ? <div className="g-mermaid-out mermaid-svg" dangerouslySetInnerHTML={{ __html: svg }} /> : null}
|
|
85
|
+
<pre className="code mermaid-src">
|
|
86
|
+
<code>{source}</code>
|
|
87
|
+
</pre>
|
|
115
88
|
</figure>
|
|
116
89
|
);
|
|
117
90
|
}
|
|
@@ -3,18 +3,14 @@ import { githubUrl, siteConfig } from "../site.config";
|
|
|
3
3
|
|
|
4
4
|
export function SiteFooter() {
|
|
5
5
|
return (
|
|
6
|
-
<footer className="
|
|
7
|
-
<div className="
|
|
6
|
+
<footer className="sh-footer">
|
|
7
|
+
<div className="sh-foot">
|
|
8
8
|
<p>
|
|
9
9
|
© {new Date().getFullYear()} {siteConfig.author.name} · {siteConfig.footer}
|
|
10
10
|
<span className="footer-framework"> · 基于 <a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a> 构建</span>
|
|
11
11
|
</p>
|
|
12
12
|
<div>
|
|
13
|
-
<a
|
|
14
|
-
href={githubUrl}
|
|
15
|
-
aria-label="GitHub"
|
|
16
|
-
rel="me"
|
|
17
|
-
>
|
|
13
|
+
<a href={githubUrl} aria-label="GitHub" rel="me">
|
|
18
14
|
<GithubLogo size={19} />
|
|
19
15
|
GitHub
|
|
20
16
|
</a>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
4
5
|
import {
|
|
5
6
|
useCallback,
|
|
6
7
|
useEffect,
|
|
@@ -10,21 +11,15 @@ import {
|
|
|
10
11
|
type KeyboardEvent,
|
|
11
12
|
} from "react";
|
|
12
13
|
import Fuse, { type FuseResultMatch } from "fuse.js";
|
|
13
|
-
import {
|
|
14
|
-
Cactus,
|
|
15
|
-
MagnifyingGlass,
|
|
16
|
-
Moon,
|
|
17
|
-
Sun,
|
|
18
|
-
X,
|
|
19
|
-
} from "@phosphor-icons/react";
|
|
14
|
+
import { MagnifyingGlass, X } from "@phosphor-icons/react";
|
|
20
15
|
import { searchRecords } from "../lib/content";
|
|
21
16
|
import { siteConfig } from "../site.config";
|
|
22
17
|
|
|
23
18
|
const nav = [
|
|
24
|
-
{ href: "/", label: "
|
|
25
|
-
{ href: "/blog", label: "博文" },
|
|
19
|
+
{ href: "/blog", label: "文章" },
|
|
26
20
|
{ href: "/thoughts", label: "随想" },
|
|
27
21
|
{ href: "/columns", label: "专栏" },
|
|
22
|
+
{ href: "/tags", label: "标签" },
|
|
28
23
|
{ href: "/about", label: "关于" },
|
|
29
24
|
];
|
|
30
25
|
|
|
@@ -141,6 +136,8 @@ function searchPreview(
|
|
|
141
136
|
}
|
|
142
137
|
|
|
143
138
|
export function SiteHeader() {
|
|
139
|
+
const pathname = usePathname() || "/";
|
|
140
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
144
141
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
145
142
|
const [query, setQuery] = useState("");
|
|
146
143
|
const [dark, setDark] = useState(false);
|
|
@@ -176,6 +173,11 @@ export function SiteHeader() {
|
|
|
176
173
|
return () => window.cancelAnimationFrame(frame);
|
|
177
174
|
}, []);
|
|
178
175
|
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
178
|
+
document.documentElement.dataset.mod = isMac ? "mac" : "other";
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
179
181
|
useEffect(() => {
|
|
180
182
|
const onKey = (event: KeyboardEvent) => {
|
|
181
183
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
@@ -272,49 +274,61 @@ export function SiteHeader() {
|
|
|
272
274
|
window.localStorage.setItem("theme", next ? "dark" : "light");
|
|
273
275
|
};
|
|
274
276
|
|
|
277
|
+
const current = (href: string) => {
|
|
278
|
+
if (href === "/blog") {
|
|
279
|
+
return pathname === "/blog" || pathname.startsWith("/blog/") || pathname.startsWith("/a2/");
|
|
280
|
+
}
|
|
281
|
+
return pathname === href || pathname.startsWith(`${href}/`);
|
|
282
|
+
};
|
|
283
|
+
|
|
275
284
|
return (
|
|
276
285
|
<>
|
|
277
|
-
<header className="
|
|
278
|
-
<div className="
|
|
279
|
-
<Link href="/" className="
|
|
280
|
-
|
|
281
|
-
<Cactus size={34} weight="thin" aria-hidden="true" />
|
|
282
|
-
</span>
|
|
283
|
-
<span>
|
|
284
|
-
<strong>{siteConfig.name}</strong>
|
|
285
|
-
<small>{siteConfig.tagline}</small>
|
|
286
|
-
</span>
|
|
287
|
-
</Link>
|
|
288
|
-
|
|
289
|
-
<nav className="main-nav" aria-label="主导航">
|
|
286
|
+
<header className="sh-header">
|
|
287
|
+
<div className="sh-bar">
|
|
288
|
+
<Link href="/" className="sh-word">{siteConfig.name}</Link>
|
|
289
|
+
<nav className="sh-nav" aria-label="站点导航">
|
|
290
290
|
{nav.map((item) => (
|
|
291
|
-
<Link key={item.href} href={item.href}>
|
|
291
|
+
<Link key={item.href} href={item.href} aria-current={current(item.href) ? "page" : undefined}>
|
|
292
292
|
{item.label}
|
|
293
293
|
</Link>
|
|
294
294
|
))}
|
|
295
295
|
</nav>
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
aria-controls="site-search-dialog"
|
|
305
|
-
>
|
|
306
|
-
<MagnifyingGlass size={22} weight="regular" />
|
|
307
|
-
<span className="key-hint">⌘K</span>
|
|
296
|
+
<div className="sh-tools">
|
|
297
|
+
<button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} aria-label="搜索文章" aria-expanded={searchOpen} aria-controls="site-search-dialog">
|
|
298
|
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
299
|
+
<circle cx="9" cy="9" r="5.8" />
|
|
300
|
+
<path d="M13.3 13.3 17 17" />
|
|
301
|
+
</svg>
|
|
302
|
+
<span className="lbl">搜索文章</span>
|
|
303
|
+
<kbd><span className="kbd-mod kbd-mac">⌘</span><span className="kbd-mod kbd-other">Ctrl</span>{" "}K</kbd>
|
|
308
304
|
</button>
|
|
309
|
-
<button
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
305
|
+
<button className="sh-icon sh-search-m" type="button" onClick={openSearch} aria-label="搜索文章">
|
|
306
|
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
307
|
+
<circle cx="9" cy="9" r="5.8" />
|
|
308
|
+
<path d="M13.3 13.3 17 17" />
|
|
309
|
+
</svg>
|
|
310
|
+
</button>
|
|
311
|
+
<button className="sh-icon sh-theme" type="button" onClick={toggleTheme} aria-label={dark ? "切换到浅色模式" : "切换到深色模式"}>
|
|
312
|
+
<svg className="i-moon" viewBox="0 0 20 20" aria-hidden="true">
|
|
313
|
+
<path d="M16.5 12.2A6.8 6.8 0 0 1 7.8 3.5a6.8 6.8 0 1 0 8.7 8.7Z" />
|
|
314
|
+
</svg>
|
|
315
|
+
<svg className="i-sun" viewBox="0 0 20 20" aria-hidden="true">
|
|
316
|
+
<circle cx="10" cy="10" r="3.6" />
|
|
317
|
+
<path d="M10 1.8v2M10 16.2v2M1.8 10h2M16.2 10h2M4.2 4.2l1.4 1.4M14.4 14.4l1.4 1.4M4.2 15.8l1.4-1.4M14.4 5.6l1.4-1.4" />
|
|
318
|
+
</svg>
|
|
319
|
+
</button>
|
|
320
|
+
<button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ? "关闭菜单" : "打开菜单"} onClick={() => setMenuOpen((open) => !open)}>
|
|
321
|
+
{menuOpen ? <X size={18} /> : <span aria-hidden="true">☰</span>}
|
|
315
322
|
</button>
|
|
316
323
|
</div>
|
|
317
324
|
</div>
|
|
325
|
+
<nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label="站点导航(移动端)">
|
|
326
|
+
{nav.map((item) => (
|
|
327
|
+
<Link key={item.href} href={item.href} aria-current={current(item.href) ? "page" : undefined} onClick={() => setMenuOpen(false)}>
|
|
328
|
+
{item.label}
|
|
329
|
+
</Link>
|
|
330
|
+
))}
|
|
331
|
+
</nav>
|
|
318
332
|
</header>
|
|
319
333
|
|
|
320
334
|
{searchOpen && (
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "react";
|
|
12
12
|
import { List, X } from "@phosphor-icons/react";
|
|
13
13
|
|
|
14
|
-
type Heading = { depth: number; text: string; id: string };
|
|
14
|
+
type Heading = { depth: number; text: string; id: string; no?: string };
|
|
15
15
|
|
|
16
16
|
type TocNode = Heading & { children: TocNode[] };
|
|
17
17
|
|
|
@@ -354,6 +354,7 @@ export function TableOfContents({
|
|
|
354
354
|
onFocus={() => prefetchHeadingLayout(node.id)}
|
|
355
355
|
aria-current={activeId === node.id ? "location" : undefined}
|
|
356
356
|
>
|
|
357
|
+
{node.no ? <span className="t-no">{node.no}</span> : null}
|
|
357
358
|
{node.text}
|
|
358
359
|
</a>
|
|
359
360
|
{node.children.length > 0 ? (
|
|
@@ -407,7 +408,7 @@ export function TableOfContents({
|
|
|
407
408
|
onClick={() => closeMobileToc(true)}
|
|
408
409
|
/>
|
|
409
410
|
<aside
|
|
410
|
-
className="toc"
|
|
411
|
+
className="toc toc-rail"
|
|
411
412
|
id={panelId}
|
|
412
413
|
aria-label={label}
|
|
413
414
|
data-mobile-open={mobileOpen}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { postHref, type ContentEntry } from "../lib/content";
|
|
7
|
+
|
|
8
|
+
function day(value: string) {
|
|
9
|
+
const date = new Date(value);
|
|
10
|
+
if (Number.isNaN(date.getTime())) return value.slice(0, 10);
|
|
11
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
12
|
+
timeZone: "Asia/Shanghai",
|
|
13
|
+
year: "numeric",
|
|
14
|
+
month: "2-digit",
|
|
15
|
+
day: "2-digit",
|
|
16
|
+
}).format(date);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function joinTags(names: string[]) {
|
|
20
|
+
if (names.length <= 1) return names[0] ?? "";
|
|
21
|
+
return `${names.slice(0, -1).join("、")}和${names[names.length - 1]}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function TagIndex({ posts }: { posts: ContentEntry[] }) {
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
const params = useSearchParams();
|
|
28
|
+
const tags = useMemo(() => {
|
|
29
|
+
const counts = new Map<string, number>();
|
|
30
|
+
for (const post of posts) {
|
|
31
|
+
for (const tag of post.tags ?? []) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0], "zh"));
|
|
34
|
+
}, [posts]);
|
|
35
|
+
const requested = params.get("t") ?? "";
|
|
36
|
+
const active = tags.some(([tag]) => tag === requested) ? requested : "";
|
|
37
|
+
const common = tags.filter(([, count]) => count > 1);
|
|
38
|
+
const once = tags.filter(([, count]) => count === 1);
|
|
39
|
+
const visible = posts
|
|
40
|
+
.filter((post) => active && (post.tags ?? []).includes(active))
|
|
41
|
+
.slice()
|
|
42
|
+
.sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
|
|
43
|
+
const choose = (tag: string) => {
|
|
44
|
+
const next = active === tag ? "" : tag;
|
|
45
|
+
router.replace(next ? `${pathname}?t=${encodeURIComponent(next)}` : pathname, { scroll: false });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="sh-listgrid" id="sh-tags">
|
|
50
|
+
<div>
|
|
51
|
+
<ul className="sh-tagcloud" aria-label="常用标签">
|
|
52
|
+
{common.map(([tag, count]) => (
|
|
53
|
+
<li key={tag}>
|
|
54
|
+
<button type="button" className="sh-chip" data-tag={tag} aria-pressed={active === tag} onClick={() => choose(tag)}>
|
|
55
|
+
{tag}<span className="n">{count}</span>
|
|
56
|
+
</button>
|
|
57
|
+
</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
60
|
+
{once.length ? (
|
|
61
|
+
<details className="sh-once" open={once.some(([tag]) => tag === active) || undefined}>
|
|
62
|
+
<summary>只出现一次的标签<span className="num">{once.length}</span><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M3.5 5.5 7 9l3.5-3.5" /></svg></summary>
|
|
63
|
+
<ul className="sh-tagcloud">
|
|
64
|
+
{once.map(([tag]) => (
|
|
65
|
+
<li key={tag}>
|
|
66
|
+
<button type="button" className="sh-chip" data-tag={tag} aria-pressed={active === tag} onClick={() => choose(tag)}>
|
|
67
|
+
{tag}
|
|
68
|
+
</button>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
</details>
|
|
73
|
+
) : null}
|
|
74
|
+
<section className="sh-tagresult" id="sh-tagresult" aria-live="polite">
|
|
75
|
+
<h2 id="sh-tag-h">{active ? `“${active}”的 ${visible.length} 篇文章` : "选择一个标签"}</h2>
|
|
76
|
+
<p className="hint" hidden={Boolean(active)}>
|
|
77
|
+
{common.length ? `还没有选择标签。常用的有 ${joinTags(common.slice(0, 4).map(([tag]) => tag))}。` : "还没有选择标签。"}
|
|
78
|
+
</p>
|
|
79
|
+
<ol className="sh-rows">
|
|
80
|
+
{visible.map((post) => (
|
|
81
|
+
<li className="sh-row" key={post.path}>
|
|
82
|
+
<time className="num" dateTime={day(post.date)}>{day(post.date)}</time>
|
|
83
|
+
<h3><Link href={postHref(post)}>{post.title}</Link></h3>
|
|
84
|
+
{post.description ? <p>{post.description}</p> : null}
|
|
85
|
+
{post.topic ? <span className="cat">{post.topic}</span> : null}
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ol>
|
|
89
|
+
</section>
|
|
90
|
+
</div>
|
|
91
|
+
<aside className="sh-rail" aria-label="说明">
|
|
92
|
+
<h2>标签与分类</h2>
|
|
93
|
+
<p>分类回答“这篇属于哪一块”,每篇一个;标签回答“提到了什么”,每篇可以有多个。</p>
|
|
94
|
+
</aside>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { readGardenPayload } from "../../lib/remark-garden.mjs";
|
|
4
|
+
import {
|
|
5
|
+
GardenCard,
|
|
6
|
+
GardenChart,
|
|
7
|
+
GardenCompare,
|
|
8
|
+
GardenFigure,
|
|
9
|
+
GardenHtml,
|
|
10
|
+
GardenIdea,
|
|
11
|
+
GardenInteractive,
|
|
12
|
+
GardenLegacy,
|
|
13
|
+
GardenStat,
|
|
14
|
+
GardenTimeline,
|
|
15
|
+
GardenUnknown,
|
|
16
|
+
} from "./blocks";
|
|
17
|
+
|
|
18
|
+
export function GardenBlock({ dataPayload }: { dataPayload?: string }) {
|
|
19
|
+
const block = readGardenPayload(dataPayload);
|
|
20
|
+
if (!block) return null;
|
|
21
|
+
if (block.legacy && block.suggestion) return <GardenLegacy suggestion={String(block.suggestion)} />;
|
|
22
|
+
if (!block.name) return <GardenIdea text={String(block.idea || block.children || "")} />;
|
|
23
|
+
if (block.error) return <GardenUnknown name={String(block.name)} />;
|
|
24
|
+
const props = (block.props ?? {}) as Record<string, unknown>;
|
|
25
|
+
switch (block.name) {
|
|
26
|
+
case "card":
|
|
27
|
+
return <GardenCard props={props} />;
|
|
28
|
+
case "stat":
|
|
29
|
+
return <GardenStat props={props} />;
|
|
30
|
+
case "compare":
|
|
31
|
+
return <GardenCompare props={props} />;
|
|
32
|
+
case "timeline":
|
|
33
|
+
return <GardenTimeline props={props} />;
|
|
34
|
+
case "chart":
|
|
35
|
+
return <GardenChart props={props} />;
|
|
36
|
+
case "figure":
|
|
37
|
+
return <GardenFigure props={props}>{String(block.children ?? "")}</GardenFigure>;
|
|
38
|
+
case "html":
|
|
39
|
+
return <GardenHtml props={props}>{String(block.children ?? "")}</GardenHtml>;
|
|
40
|
+
case "interactive":
|
|
41
|
+
return <GardenInteractive block={{ id: block.id, props, children: block.children }} />;
|
|
42
|
+
default:
|
|
43
|
+
return <GardenUnknown name={String(block.name)} />;
|
|
44
|
+
}
|
|
45
|
+
}
|