hd-garden 0.3.12 → 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/admin/page.tsx +11 -15
- package/app/admin/ui/page.tsx +1 -1
- 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/AdminDashboard.tsx +24 -34
- package/app/components/AdminUiPanels.tsx +5 -5
- package/app/components/AdminUiShell.tsx +7 -7
- package/app/components/ArticleMeasure.tsx +95 -0
- package/app/components/ArticleTitle.tsx +20 -0
- package/app/components/BlogArchive.tsx +106 -102
- package/app/components/GardenAdminGuide.tsx +38 -45
- package/app/components/GardenContentGuide.tsx +22 -106
- package/app/components/GardenMediaGuide.tsx +24 -45
- package/app/components/MarkdownArticle.tsx +145 -66
- package/app/components/MermaidDiagram.tsx +44 -71
- package/app/components/PageIntro.tsx +2 -2
- 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/UiStudio.tsx +2 -2
- 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/page.tsx +85 -223
- package/app/garden-modules.d.ts +2 -0
- package/app/globals.css +947 -88
- 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/fallback-site-config.ts +1 -1
- 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 +2 -2
- package/public/images/garden/admin-studio.png +0 -0
- 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 +2 -2
- package/templates/garden-blocks.md +128 -0
- package/templates/garden-snippets.plist +121 -0
- package/templates/site/site.config.yaml +1 -1
- 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
|
@@ -10,11 +10,6 @@ function SourcePanel({ path, source, language }: SourcePanelProps) {
|
|
|
10
10
|
return (
|
|
11
11
|
<div className="garden-source-panel">
|
|
12
12
|
<div className="garden-source-bar">
|
|
13
|
-
<span aria-hidden="true">
|
|
14
|
-
<i />
|
|
15
|
-
<i />
|
|
16
|
-
<i />
|
|
17
|
-
</span>
|
|
18
13
|
<code>{path}</code>
|
|
19
14
|
</div>
|
|
20
15
|
<pre>
|
|
@@ -69,12 +64,10 @@ const columnSource = `columns:
|
|
|
69
64
|
|
|
70
65
|
type Guide = {
|
|
71
66
|
id: string;
|
|
72
|
-
number: string;
|
|
73
67
|
label: string;
|
|
74
68
|
title: string;
|
|
75
69
|
introduction: string;
|
|
76
70
|
steps: ReactNode[];
|
|
77
|
-
features: string[];
|
|
78
71
|
result: string;
|
|
79
72
|
path: string;
|
|
80
73
|
source: string;
|
|
@@ -84,88 +77,45 @@ type Guide = {
|
|
|
84
77
|
const guides: Guide[] = [
|
|
85
78
|
{
|
|
86
79
|
id: "write-post",
|
|
87
|
-
number: "01",
|
|
88
80
|
label: "博文",
|
|
89
81
|
title: "写一篇文章",
|
|
90
|
-
introduction:
|
|
91
|
-
"博文保存在 content/posts/ 中,可以按照主题建立任意层级的子目录。文件路径同时决定文章地址,正文仍然是普通 Markdown。",
|
|
82
|
+
introduction: "文件放在 content/posts/。路径就是网址,正文是普通 Markdown。可以按主题分子目录。",
|
|
92
83
|
steps: [
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
<>在 Front Matter 中填写标题、日期、摘要、主题、标签和封面。</>,
|
|
97
|
-
<>使用 Markdown 编写正文,图片和其他附件可以使用相对路径。</>,
|
|
84
|
+
<>在 <code>content/posts/</code> 里新建一个 <code>.md</code> 文件。</>,
|
|
85
|
+
<>开头写下标题、日期、摘要、主题和标签。封面可有可无。</>,
|
|
86
|
+
<>接着写正文。图片用相对路径,和文件放在一起。</>,
|
|
98
87
|
],
|
|
99
|
-
|
|
100
|
-
"GFM 表格、任务列表与脚注",
|
|
101
|
-
"TypeScript、Go、Rust 等代码高亮",
|
|
102
|
-
"数学公式与 Mermaid 图表",
|
|
103
|
-
"图片、音视频与平台嵌入",
|
|
104
|
-
"草稿、定时发布与置顶",
|
|
105
|
-
"文章目录、上一篇与下一篇",
|
|
106
|
-
],
|
|
107
|
-
result:
|
|
108
|
-
"构建时会生成文章页面,并把标题、摘要和正文加入博文列表、主题归档、全文搜索与 RSS。",
|
|
88
|
+
result: "推送后,这篇文章会出现在列表、主题、搜索和 RSS 里。",
|
|
109
89
|
path: "content/posts/writing/first-post.md",
|
|
110
90
|
source: postSource,
|
|
111
91
|
language: "markdown",
|
|
112
92
|
},
|
|
113
93
|
{
|
|
114
94
|
id: "write-thought",
|
|
115
|
-
number: "02",
|
|
116
95
|
label: "随想",
|
|
117
|
-
title: "
|
|
118
|
-
introduction:
|
|
119
|
-
"随想适合记录几句话、一个链接或一组多媒体。它同样保存在 Git 仓库中,只需要更少的字段,也可以通过可选管理后台发布。",
|
|
96
|
+
title: "写一条随想",
|
|
97
|
+
introduction: "几句话、一张图或一个链接。字段更少。也可以在后台写,写完同样进仓库。",
|
|
120
98
|
steps: [
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<>文件名就是地址。front matter 填写标题和日期;需要暂存时加入草稿状态。</>,
|
|
125
|
-
<>
|
|
126
|
-
在 <code>media</code> 中按顺序添加图片、音频、视频或外部链接。
|
|
127
|
-
</>,
|
|
128
|
-
],
|
|
129
|
-
features: [
|
|
130
|
-
"纯文字短内容",
|
|
131
|
-
"多张图片与混合图集",
|
|
132
|
-
"音频和视频",
|
|
133
|
-
"带标题的外部链接",
|
|
134
|
-
"草稿与公开状态",
|
|
135
|
-
"独立永久链接",
|
|
99
|
+
<>新建 <code>content/thoughts/文件名.md</code>,或在后台填写。</>,
|
|
100
|
+
<>文件名就是地址。标题和日期写在开头。先存着就标成草稿。</>,
|
|
101
|
+
<>图片、音频、视频和链接按顺序放进 <code>media</code>。</>,
|
|
136
102
|
],
|
|
137
|
-
result:
|
|
138
|
-
"发布后的内容会出现在随想时间线和首页最近随想中,每条随想都有可以单独分享的永久链接。",
|
|
103
|
+
result: "它会出现在随想页和首页,并且有自己的链接。",
|
|
139
104
|
path: "content/thoughts/walk-after-rain.md",
|
|
140
105
|
source: thoughtSource,
|
|
141
106
|
language: "markdown",
|
|
142
107
|
},
|
|
143
108
|
{
|
|
144
109
|
id: "make-column",
|
|
145
|
-
number: "03",
|
|
146
110
|
label: "专栏",
|
|
147
|
-
title: "
|
|
148
|
-
introduction:
|
|
149
|
-
"专栏不复制文章,而是在 content/columns.yaml 中引用已经存在的博文。posts 的排列顺序就是专栏的阅读顺序。",
|
|
111
|
+
title: "把文章收成专栏",
|
|
112
|
+
introduction: "专栏不复制文章。它只是 content/columns.yaml 里的一份阅读顺序。",
|
|
150
113
|
steps: [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<>
|
|
155
|
-
在 <code>posts</code> 中填写博文相对 <code>content/posts/</code> 的路径。
|
|
156
|
-
</>,
|
|
157
|
-
<>按照希望读者阅读的顺序排列路径;同一篇文章可以加入多个专栏。</>,
|
|
158
|
-
],
|
|
159
|
-
features: [
|
|
160
|
-
"专栏标题、说明和状态",
|
|
161
|
-
"独立专栏封面",
|
|
162
|
-
"文章顺序可控",
|
|
163
|
-
"复用同一份 Markdown",
|
|
164
|
-
"一篇文章加入多个专栏",
|
|
165
|
-
"系列文章导航",
|
|
114
|
+
<>在 <code>content/columns.yaml</code> 里写名称、介绍和封面。</>,
|
|
115
|
+
<>在 <code>posts</code> 里填文章相对 <code>content/posts/</code> 的路径。</>,
|
|
116
|
+
<>按你希望读者阅读的顺序排列。同一篇可以进多个专栏。</>,
|
|
166
117
|
],
|
|
167
|
-
result:
|
|
168
|
-
"构建后会生成独立专栏页面。读者可以按照配置顺序浏览文章,文章页也会显示它所属的系列与前后篇目。",
|
|
118
|
+
result: "读者按这个顺序读。文章页也会显示它属于哪一组,以及上一篇、下一篇。",
|
|
169
119
|
path: "content/columns.yaml",
|
|
170
120
|
source: columnSource,
|
|
171
121
|
language: "yaml",
|
|
@@ -174,56 +124,22 @@ const guides: Guide[] = [
|
|
|
174
124
|
|
|
175
125
|
export function GardenContentGuide() {
|
|
176
126
|
return (
|
|
177
|
-
<
|
|
178
|
-
<header className="garden-guide-heading">
|
|
179
|
-
<div>
|
|
180
|
-
<p className="eyebrow">CONTENT / 内容</p>
|
|
181
|
-
<h2 id="garden-guide-title">博文、随想和专栏</h2>
|
|
182
|
-
</div>
|
|
183
|
-
<p>
|
|
184
|
-
先选择内容类型,再编辑对应文件。三种内容都保存在源码仓库中,直接编辑文件即可完成全部公开内容的维护和发布。
|
|
185
|
-
</p>
|
|
186
|
-
</header>
|
|
187
|
-
|
|
188
|
-
<nav className="garden-guide-index" aria-label="内容类型">
|
|
189
|
-
{guides.map((guide) => (
|
|
190
|
-
<a href={`#${guide.id}`} key={guide.id}>
|
|
191
|
-
<span>{guide.number}</span>
|
|
192
|
-
<strong>{guide.title}</strong>
|
|
193
|
-
<small>{guide.label}</small>
|
|
194
|
-
</a>
|
|
195
|
-
))}
|
|
196
|
-
</nav>
|
|
197
|
-
|
|
127
|
+
<div className="garden-guide">
|
|
198
128
|
<div className="garden-guide-sections">
|
|
199
129
|
{guides.map((guide) => (
|
|
200
130
|
<article className="garden-content-guide" id={guide.id} key={guide.id}>
|
|
201
131
|
<div className="garden-content-copy">
|
|
202
|
-
<p className="
|
|
203
|
-
{guide.number} / {guide.label}
|
|
204
|
-
</p>
|
|
132
|
+
<p className="garden-content-label">{guide.label}</p>
|
|
205
133
|
<h2>{guide.title}</h2>
|
|
206
134
|
<p className="garden-content-introduction">{guide.introduction}</p>
|
|
207
135
|
|
|
208
136
|
<ol className="garden-content-steps">
|
|
209
137
|
{guide.steps.map((step, index) => (
|
|
210
|
-
<li key={index}>
|
|
211
|
-
<strong>{index + 1}</strong>
|
|
212
|
-
<span>{step}</span>
|
|
213
|
-
</li>
|
|
138
|
+
<li key={index}>{step}</li>
|
|
214
139
|
))}
|
|
215
140
|
</ol>
|
|
216
141
|
|
|
217
|
-
<
|
|
218
|
-
{guide.features.map((feature) => (
|
|
219
|
-
<li key={feature}>{feature}</li>
|
|
220
|
-
))}
|
|
221
|
-
</ul>
|
|
222
|
-
|
|
223
|
-
<aside className="garden-content-result">
|
|
224
|
-
<strong>发布后</strong>
|
|
225
|
-
<p>{guide.result}</p>
|
|
226
|
-
</aside>
|
|
142
|
+
<p className="garden-content-result">{guide.result}</p>
|
|
227
143
|
</div>
|
|
228
144
|
|
|
229
145
|
<SourcePanel
|
|
@@ -234,6 +150,6 @@ export function GardenContentGuide() {
|
|
|
234
150
|
</article>
|
|
235
151
|
))}
|
|
236
152
|
</div>
|
|
237
|
-
</
|
|
153
|
+
</div>
|
|
238
154
|
);
|
|
239
155
|
}
|
|
@@ -1,30 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Globe,
|
|
3
|
-
VideoCamera,
|
|
4
|
-
WechatLogo,
|
|
5
|
-
XLogo,
|
|
6
|
-
} from "@phosphor-icons/react/ssr";
|
|
7
|
-
|
|
8
1
|
const embedTypes = [
|
|
9
2
|
{
|
|
10
3
|
title: "YouTube / Bilibili",
|
|
11
|
-
description: "
|
|
12
|
-
Icon: VideoCamera,
|
|
4
|
+
description: "贴上分享链接,页面里就能播放,也能回到原站。",
|
|
13
5
|
},
|
|
14
6
|
{
|
|
15
7
|
title: "X / Twitter",
|
|
16
|
-
description: "
|
|
17
|
-
Icon: XLogo,
|
|
8
|
+
description: "显示成一张带出处的卡片,不依赖对方的脚本。",
|
|
18
9
|
},
|
|
19
10
|
{
|
|
20
11
|
title: "微信公众号",
|
|
21
|
-
description: "
|
|
22
|
-
Icon: WechatLogo,
|
|
12
|
+
description: "留下标题、摘要和原文链接。微信不允许嵌进页面时,卡片仍然能点开。",
|
|
23
13
|
},
|
|
24
14
|
{
|
|
25
15
|
title: "其他网站",
|
|
26
|
-
description: "
|
|
27
|
-
Icon: Globe,
|
|
16
|
+
description: "自己写标题、摘要和封面。不在构建时去抓那个网址。",
|
|
28
17
|
},
|
|
29
18
|
];
|
|
30
19
|
|
|
@@ -39,43 +28,33 @@ export function GardenMediaGuide() {
|
|
|
39
28
|
return (
|
|
40
29
|
<section className="garden-media" id="media" aria-labelledby="garden-media-title">
|
|
41
30
|
<header>
|
|
42
|
-
<
|
|
43
|
-
<h2 id="garden-media-title">文章不只能放文字。</h2>
|
|
31
|
+
<h2 id="garden-media-title">视频和链接</h2>
|
|
44
32
|
<p>
|
|
45
|
-
|
|
46
|
-
<code> embed </code
|
|
33
|
+
自己的图片、音频和视频用 Markdown。别人的页面用
|
|
34
|
+
<code> embed </code>
|
|
35
|
+
围栏。能播的直接播,不能播的显示成一张卡片。
|
|
47
36
|
</p>
|
|
48
37
|
</header>
|
|
49
38
|
|
|
50
|
-
<div className="garden-media-
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
<li key={title}>
|
|
54
|
-
<span>{String(index + 1).padStart(2, "0")}</span>
|
|
55
|
-
<Icon size={22} aria-hidden="true" />
|
|
56
|
-
<div>
|
|
57
|
-
<strong>{title}</strong>
|
|
58
|
-
<p>{description}</p>
|
|
59
|
-
</div>
|
|
60
|
-
</li>
|
|
61
|
-
))}
|
|
62
|
-
</ul>
|
|
63
|
-
|
|
64
|
-
<div className="garden-media-source">
|
|
65
|
-
<div>
|
|
66
|
-
<span>content/posts/example.md</span>
|
|
67
|
-
<small>Markdown</small>
|
|
68
|
-
</div>
|
|
69
|
-
<pre><code>{embedSource}</code></pre>
|
|
39
|
+
<div className="garden-media-source">
|
|
40
|
+
<div>
|
|
41
|
+
<span>content/posts/example.md</span>
|
|
70
42
|
</div>
|
|
43
|
+
<pre><code>{embedSource}</code></pre>
|
|
71
44
|
</div>
|
|
72
45
|
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
46
|
+
<ul className="garden-media-providers">
|
|
47
|
+
{embedTypes.map(({ title, description }) => (
|
|
48
|
+
<li key={title}>
|
|
49
|
+
<strong>{title}</strong>
|
|
50
|
+
<p>{description}</p>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
|
|
55
|
+
<p className="garden-media-boundary">
|
|
56
|
+
摘要写在文章里。构建时不去抓任意网址。原站打不开,文章还在。需要时用 <code>make embed URL=...</code> 拉一次,看过再贴进去。
|
|
57
|
+
</p>
|
|
79
58
|
</section>
|
|
80
59
|
);
|
|
81
60
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { markdownPlugins } from "../lib/markdown-plugins";
|
|
2
|
+
import { remarkA2 } from "../lib/remark-a2.mjs";
|
|
2
3
|
import {
|
|
3
4
|
Children,
|
|
5
|
+
cloneElement,
|
|
4
6
|
isValidElement,
|
|
5
7
|
type HTMLAttributes,
|
|
8
|
+
type ReactNode,
|
|
6
9
|
} from "react";
|
|
10
|
+
import { articleHeadingBase, articleUsesHeadingNumbers, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
|
|
7
11
|
import ReactMarkdown from "react-markdown";
|
|
8
12
|
import rehypeRaw from "rehype-raw";
|
|
9
13
|
import rehypeKatex from "rehype-katex";
|
|
@@ -12,40 +16,32 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
|
|
12
16
|
import rehypeHighlight from "rehype-highlight";
|
|
13
17
|
import { MermaidDiagram } from "./MermaidDiagram";
|
|
14
18
|
import { MarkdownImage } from "./MarkdownImage";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
19
|
+
import { CodeCopy } from "./garden/chrome";
|
|
20
|
+
import { CodeDiff } from "./garden/chrome";
|
|
21
|
+
import { GardenBlock } from "./garden/GardenBlock";
|
|
18
22
|
|
|
19
|
-
const BLOCK_LANGUAGES = ["language-mermaid"
|
|
23
|
+
const BLOCK_LANGUAGES = ["language-mermaid"];
|
|
20
24
|
const isBlockLanguage = (className?: string) =>
|
|
21
25
|
Boolean(className) && BLOCK_LANGUAGES.some((language) => className?.split(/\s+/u).includes(language));
|
|
22
26
|
|
|
27
|
+
function codeText(node: unknown): string {
|
|
28
|
+
if (!node || typeof node !== "object") return "";
|
|
29
|
+
const record = node as { type?: string; value?: string; children?: unknown[] };
|
|
30
|
+
if (typeof record.value === "string" && record.type === "text") return record.value;
|
|
31
|
+
return (record.children ?? []).map((child) => codeText(child)).join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
function CodeBlock({
|
|
24
35
|
className,
|
|
25
36
|
children,
|
|
37
|
+
node,
|
|
26
38
|
...props
|
|
27
39
|
}: HTMLAttributes<HTMLElement> & { node?: unknown }) {
|
|
28
40
|
if (className?.includes("language-mermaid")) {
|
|
29
|
-
return <MermaidDiagram source={String(children).trim()} />;
|
|
30
|
-
}
|
|
31
|
-
if (className?.includes("language-embed")) {
|
|
32
|
-
return <ExternalEmbed source={String(children).trim()} />;
|
|
33
|
-
}
|
|
34
|
-
if (className?.includes("language-widget")) {
|
|
35
|
-
return <WidgetChart source={String(children).trim()} />;
|
|
36
|
-
}
|
|
37
|
-
if (className?.split(/\s+/u).includes("language-ui")) {
|
|
38
|
-
return <UiBlock source={String(children)} />;
|
|
39
|
-
}
|
|
40
|
-
if (className?.split(/\s+/u).includes("language-ui-error")) {
|
|
41
|
-
return <UiErrorBlock source={String(children)} />;
|
|
42
|
-
}
|
|
43
|
-
if (className?.split(/\s+/u).includes("language-intent")) {
|
|
44
|
-
return <IntentBlock source={String(children)} />;
|
|
41
|
+
return <MermaidDiagram source={codeText(node) || String(children ?? "").trim()} />;
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
const codeProps = { ...props };
|
|
48
|
-
delete codeProps.node;
|
|
49
45
|
|
|
50
46
|
return (
|
|
51
47
|
<code className={className} {...codeProps}>
|
|
@@ -54,51 +50,113 @@ function CodeBlock({
|
|
|
54
50
|
);
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const marker = line.match(/^\s*(`{3,}|~{3,})/)?.[1];
|
|
74
|
-
if (marker) { if (!fence) fence = marker[0]; else if (marker[0] === fence) fence = null; return [line]; }
|
|
75
|
-
if (fence) return [line];
|
|
76
|
-
const legacyMarker = line.match(/garden-callout-marker--(info|success|warning|danger)/u)?.[1];
|
|
77
|
-
if (legacyMarker) {
|
|
78
|
-
pendingLegacyLabel = true;
|
|
79
|
-
return [line];
|
|
53
|
+
function stripPrefix(children: ReactNode, prefix: string): ReactNode {
|
|
54
|
+
let rest = prefix;
|
|
55
|
+
const walk = (node: ReactNode): ReactNode => {
|
|
56
|
+
if (!rest) return node;
|
|
57
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
58
|
+
const value = String(node);
|
|
59
|
+
if (value.startsWith(rest)) {
|
|
60
|
+
const kept = value.slice(rest.length);
|
|
61
|
+
rest = "";
|
|
62
|
+
return kept;
|
|
63
|
+
}
|
|
64
|
+
if (rest.startsWith(value)) {
|
|
65
|
+
rest = rest.slice(value.length);
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
80
69
|
}
|
|
81
|
-
if (
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
const label = line.match(/^>\s*\*\*(.+?)\*\*\s*$/u)?.[1].trim();
|
|
85
|
-
if (label && automaticCalloutLabels.has(label)) return [];
|
|
70
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
71
|
+
if (isValidElement<{ children?: ReactNode }>(node)) {
|
|
72
|
+
return cloneElement(node, undefined, walk(node.props.children));
|
|
86
73
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const title = match?.[2].trim();
|
|
91
|
-
const lines = [`> <span class="garden-callout-marker garden-callout-marker--${callout.tone}" aria-hidden="true"></span>`, ">"];
|
|
92
|
-
if (title && !automaticCalloutLabels.has(title)) lines.push(`> **${title}**`, ">");
|
|
93
|
-
return lines;
|
|
94
|
-
}).join("\n");
|
|
74
|
+
return node;
|
|
75
|
+
};
|
|
76
|
+
return walk(children);
|
|
95
77
|
}
|
|
96
78
|
|
|
79
|
+
function LinkCard({ href }: { href: string }) {
|
|
80
|
+
let host = href;
|
|
81
|
+
try { host = new URL(href).hostname.replace(/^www\./u, ""); } catch { /* keep the raw url */ }
|
|
82
|
+
return (
|
|
83
|
+
<ul className="link-cards">
|
|
84
|
+
<li>
|
|
85
|
+
<a className="link-card ext" href={href} target="_blank" rel="noopener noreferrer">
|
|
86
|
+
<span className="lc-body">
|
|
87
|
+
<span className="lc-site">
|
|
88
|
+
<span className="lc-sitename">{host}</span>
|
|
89
|
+
<span className="lc-host">{host}</span>
|
|
90
|
+
<span className="lc-arrow" aria-hidden="true" />
|
|
91
|
+
</span>
|
|
92
|
+
<span className="lc-title">{href}</span>
|
|
93
|
+
</span>
|
|
94
|
+
</a>
|
|
95
|
+
</li>
|
|
96
|
+
</ul>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const headingText = (children: ReactNode) => {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
const walk = (node: ReactNode) => {
|
|
103
|
+
if (typeof node === "string" || typeof node === "number") parts.push(String(node));
|
|
104
|
+
else if (Array.isArray(node)) node.forEach(walk);
|
|
105
|
+
else if (isValidElement<{ children?: React.ReactNode }>(node)) walk(node.props.children);
|
|
106
|
+
};
|
|
107
|
+
walk(children);
|
|
108
|
+
return parts.join("");
|
|
109
|
+
};
|
|
110
|
+
|
|
97
111
|
export function MarkdownArticle({ content }: { content: string }) {
|
|
112
|
+
const numbers = { h2: 0, h3: 0 };
|
|
113
|
+
const numberedArticle = articleUsesHeadingNumbers(content);
|
|
114
|
+
const headingBase = articleHeadingBase(content);
|
|
115
|
+
const section = (depth: 2 | 3, children: ReactNode) => {
|
|
116
|
+
const raw = headingText(children).trim();
|
|
117
|
+
const split = splitHeadingNumber(raw);
|
|
118
|
+
if (split) {
|
|
119
|
+
if (depth === 2) { numbers.h2 += 1; numbers.h3 = 0; }
|
|
120
|
+
else numbers.h3 += 1;
|
|
121
|
+
const prefix = raw.slice(0, raw.length - split.text.length);
|
|
122
|
+
return { no: split.no, children: stripPrefix(children, prefix) };
|
|
123
|
+
}
|
|
124
|
+
if (numberedArticle) return { no: "", children };
|
|
125
|
+
if (depth === 2) { numbers.h2 += 1; numbers.h3 = 0; return { no: String(numbers.h2), children }; }
|
|
126
|
+
numbers.h3 += 1;
|
|
127
|
+
return { no: numbers.h2 ? `${numbers.h2}.${numbers.h3}` : String(numbers.h3), children };
|
|
128
|
+
};
|
|
129
|
+
const heading = (source: 2 | 3 | 4, children: ReactNode, props: HTMLAttributes<HTMLHeadingElement> & { node?: unknown }) => {
|
|
130
|
+
const level = displayHeadingLevel(source, headingBase);
|
|
131
|
+
const Tag = level === 2 ? "h2" : level === 3 ? "h3" : "h4";
|
|
132
|
+
const domProps = { ...props };
|
|
133
|
+
Reflect.deleteProperty(domProps, "node");
|
|
134
|
+
if (level > 3) return <Tag {...domProps}>{children}</Tag>;
|
|
135
|
+
const sectioned = section(level as 2 | 3, children);
|
|
136
|
+
return <Tag {...domProps}>{sectioned.no ? <span className="sec-no">{sectioned.no}</span> : null}{sectioned.children}</Tag>;
|
|
137
|
+
};
|
|
138
|
+
const gardenComponents = {
|
|
139
|
+
"garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => (
|
|
140
|
+
<GardenBlock dataPayload={props.dataPayload ?? props["data-payload"]} />
|
|
141
|
+
),
|
|
142
|
+
"garden-codediff": (props: {
|
|
143
|
+
children?: ReactNode;
|
|
144
|
+
dataDiff?: string;
|
|
145
|
+
dataNew?: string;
|
|
146
|
+
"data-diff"?: string;
|
|
147
|
+
"data-new"?: string;
|
|
148
|
+
}) => (
|
|
149
|
+
<CodeDiff
|
|
150
|
+
code={props.children}
|
|
151
|
+
diff={decodeURIComponent(props.dataDiff ?? props["data-diff"] ?? "")}
|
|
152
|
+
next={decodeURIComponent(props.dataNew ?? props["data-new"] ?? "")}
|
|
153
|
+
/>
|
|
154
|
+
),
|
|
155
|
+
};
|
|
98
156
|
return (
|
|
99
|
-
<div className="markdown-body">
|
|
157
|
+
<div className="markdown-body prose">
|
|
100
158
|
<ReactMarkdown
|
|
101
|
-
remarkPlugins={markdownPlugins}
|
|
159
|
+
remarkPlugins={[...markdownPlugins, remarkA2]}
|
|
102
160
|
rehypePlugins={[
|
|
103
161
|
rehypeRaw,
|
|
104
162
|
rehypeKatex,
|
|
@@ -107,14 +165,24 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
107
165
|
rehypeHighlight,
|
|
108
166
|
]}
|
|
109
167
|
components={{
|
|
168
|
+
p: ({ children, ...props }) => {
|
|
169
|
+
const domProps = { ...props };
|
|
170
|
+
Reflect.deleteProperty(domProps, "node");
|
|
171
|
+
const nodes = Children.toArray(children).filter((node) => !(typeof node === "string" && !node.trim()));
|
|
172
|
+
const only = nodes.length === 1 && isValidElement<{ href?: string; children?: ReactNode }>(nodes[0]) ? nodes[0] : null;
|
|
173
|
+
const href = only?.props.href;
|
|
174
|
+
const text = only ? headingText(only.props.children).trim() : "";
|
|
175
|
+
if (href && text === href && /^https?:\/\//iu.test(href)) return <LinkCard href={href} />;
|
|
176
|
+
return <p {...domProps}>{children}</p>;
|
|
177
|
+
},
|
|
110
178
|
a: ({ href, children, ...props }) => {
|
|
111
|
-
const external = href
|
|
179
|
+
const external = Boolean(href && /^(https?:)?\/\//iu.test(href));
|
|
112
180
|
return (
|
|
113
181
|
<a
|
|
114
182
|
href={href}
|
|
115
183
|
{...props}
|
|
116
184
|
{...(external
|
|
117
|
-
? { target: "_blank", rel: "noopener noreferrer" }
|
|
185
|
+
? { className: "ext", target: "_blank", rel: "noopener noreferrer" }
|
|
118
186
|
: {})}
|
|
119
187
|
>
|
|
120
188
|
{children}
|
|
@@ -122,9 +190,18 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
122
190
|
);
|
|
123
191
|
},
|
|
124
192
|
img: MarkdownImage,
|
|
193
|
+
table: ({ children, ...props }) => (
|
|
194
|
+
<div className="table-wrap" role="region" aria-label="表格,可横向滚动" tabIndex={0}>
|
|
195
|
+
<table {...props}>{children}</table>
|
|
196
|
+
</div>
|
|
197
|
+
),
|
|
198
|
+
h2: ({ children, ...props }) => heading(2, children, props),
|
|
199
|
+
h3: ({ children, ...props }) => heading(3, children, props),
|
|
200
|
+
h4: ({ children, ...props }) => heading(4, children, props),
|
|
125
201
|
code: CodeBlock,
|
|
202
|
+
...gardenComponents,
|
|
126
203
|
pre: ({ children, ...props }) => {
|
|
127
|
-
const child = Children.
|
|
204
|
+
const child = Children.toArray(children)[0];
|
|
128
205
|
const className = isValidElement<{ className?: string }>(child)
|
|
129
206
|
? child.props.className
|
|
130
207
|
: undefined;
|
|
@@ -137,14 +214,16 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
137
214
|
}
|
|
138
215
|
|
|
139
216
|
return (
|
|
140
|
-
<
|
|
141
|
-
{
|
|
142
|
-
|
|
217
|
+
<CodeCopy>
|
|
218
|
+
<pre className="code" {...preProps} data-language={language?.toUpperCase()}>
|
|
219
|
+
{children}
|
|
220
|
+
</pre>
|
|
221
|
+
</CodeCopy>
|
|
143
222
|
);
|
|
144
223
|
},
|
|
145
224
|
}}
|
|
146
225
|
>
|
|
147
|
-
{
|
|
226
|
+
{content}
|
|
148
227
|
</ReactMarkdown>
|
|
149
228
|
</div>
|
|
150
229
|
);
|