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.
Files changed (72) hide show
  1. package/app/a2/[slug]/page.tsx +61 -0
  2. package/app/a2/page.tsx +27 -0
  3. package/app/admin/page.tsx +11 -15
  4. package/app/admin/ui/page.tsx +1 -1
  5. package/app/blog/[...path]/page.tsx +33 -22
  6. package/app/blog/page.tsx +6 -7
  7. package/app/columns/[column]/[...path]/page.tsx +33 -25
  8. package/app/columns/page.tsx +38 -52
  9. package/app/components/AdminDashboard.tsx +24 -34
  10. package/app/components/AdminUiPanels.tsx +5 -5
  11. package/app/components/AdminUiShell.tsx +7 -7
  12. package/app/components/ArticleMeasure.tsx +95 -0
  13. package/app/components/ArticleTitle.tsx +20 -0
  14. package/app/components/BlogArchive.tsx +106 -102
  15. package/app/components/GardenAdminGuide.tsx +38 -45
  16. package/app/components/GardenContentGuide.tsx +22 -106
  17. package/app/components/GardenMediaGuide.tsx +24 -45
  18. package/app/components/MarkdownArticle.tsx +145 -66
  19. package/app/components/MermaidDiagram.tsx +44 -71
  20. package/app/components/PageIntro.tsx +2 -2
  21. package/app/components/SiteFooter.tsx +3 -7
  22. package/app/components/SiteHeader.tsx +55 -41
  23. package/app/components/TableOfContents.tsx +3 -2
  24. package/app/components/TagIndex.tsx +97 -0
  25. package/app/components/UiStudio.tsx +2 -2
  26. package/app/components/garden/GardenBlock.tsx +45 -0
  27. package/app/components/garden/blocks.tsx +530 -0
  28. package/app/components/garden/chrome.tsx +350 -0
  29. package/app/components/garden/sandbox-message.ts +16 -0
  30. package/app/garden/page.tsx +85 -223
  31. package/app/garden-modules.d.ts +2 -0
  32. package/app/globals.css +947 -88
  33. package/app/layout.tsx +9 -0
  34. package/app/lib/a2-meta.ts +22 -0
  35. package/app/lib/a2-preview.ts +50 -0
  36. package/app/lib/content.ts +48 -5
  37. package/app/lib/fallback-site-config.ts +1 -1
  38. package/app/lib/markdown-plugins.ts +2 -0
  39. package/app/lib/remark-a2.mjs +208 -0
  40. package/app/lib/remark-garden.mjs +88 -0
  41. package/app/page.tsx +122 -93
  42. package/app/styles/a2.css +1383 -0
  43. package/app/styles/shell.css +438 -0
  44. package/app/styles/tokens.css +111 -0
  45. package/app/tags/page.tsx +28 -0
  46. package/app/ui-parity/page.tsx +3 -2
  47. package/package.json +2 -2
  48. package/public/images/garden/admin-studio.png +0 -0
  49. package/scripts/build-content.mjs +11 -1
  50. package/scripts/check-post.mjs +18 -4
  51. package/scripts/new-post.mjs +3 -0
  52. package/site.config.yaml +2 -2
  53. package/templates/garden-blocks.md +128 -0
  54. package/templates/garden-snippets.plist +121 -0
  55. package/templates/site/site.config.yaml +1 -1
  56. package/ui/garden/catalog.mjs +196 -0
  57. package/ui/garden/check.mjs +103 -0
  58. package/ui/garden/enhance.mjs +89 -0
  59. package/ui/garden/parse.mjs +198 -0
  60. package/ui/garden/snippets.mjs +109 -0
  61. package/ui/garden/title.mjs +37 -0
  62. package/ui/garden/word-count.mjs +266 -0
  63. package/ui/host.mjs +45 -28
  64. package/ui/parity-fixtures.mjs +1 -1
  65. package/ui/preview/dist/garden-ui.css +81 -77
  66. package/ui/preview/dist/garden-ui.esm.js +124 -89
  67. package/ui/preview/dist/garden-ui.iife.js +124 -89
  68. package/ui/prompt/rules.md +17 -1
  69. package/ui/react/CatalogPage.tsx +12 -0
  70. package/ui/react/components/display.tsx +1 -1
  71. package/ui/react/components/media.tsx +2 -2
  72. 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
- 在 <code>content/posts/</code> 中创建一个 <code>.md</code> 文件。
95
- </>,
96
- <>在 Front Matter 中填写标题、日期、摘要、主题、标签和封面。</>,
97
- <>使用 Markdown 编写正文,图片和其他附件可以使用相对路径。</>,
84
+ <>在 <code>content/posts/</code> 里新建一个 <code>.md</code> 文件。</>,
85
+ <>开头写下标题、日期、摘要、主题和标签。封面可有可无。</>,
86
+ <>接着写正文。图片用相对路径,和文件放在一起。</>,
98
87
  ],
99
- features: [
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
- 直接创建 <code>content/thoughts/&lt;文件名&gt;.md</code>,或者在管理后台填写内容。
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
- 在 <code>content/columns.yaml</code> 中添加专栏名称、介绍、状态和封面。
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
- <section className="garden-guide" aria-labelledby="garden-guide-title">
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="eyebrow">
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
- <ul className="garden-content-features" aria-label={`${guide.title}支持的能力`}>
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
- </section>
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: "保留标题、摘要和原文链接;微信禁止稳定 iframe 时仍有完整降级体验。",
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
- <p className="eyebrow">EMBEDS / 高级嵌入</p>
43
- <h2 id="garden-media-title">文章不只能放文字。</h2>
31
+ <h2 id="garden-media-title">视频和链接</h2>
44
32
  <p>
45
- 本地图片、音频和视频继续使用标准 Markdown / HTML;外部平台统一使用
46
- <code> embed </code>代码块。可播放的平台直接播放,受平台限制的内容自动退化为摘要卡片。
33
+ 自己的图片、音频和视频用 Markdown。别人的页面用
34
+ <code> embed </code>
35
+ 围栏。能播的直接播,不能播的显示成一张卡片。
47
36
  </p>
48
37
  </header>
49
38
 
50
- <div className="garden-media-layout">
51
- <ul className="garden-media-providers">
52
- {embedTypes.map(({ title, description, Icon }, index) => (
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
- <aside className="garden-media-boundary">
74
- <strong>为什么不自动抓网页摘要?</strong>
75
- <p>
76
- 构建阶段主动抓取任意 URL 会带来 SSRF、超时和内容漂移风险。Garden 选择把摘要写进 Markdown:发布结果可预测,原站失效时文章也仍然可读。写稿时可用 <code>make embed URL=...</code> 拉一次 Open Graph,检查后再贴进围栏。
77
- </p>
78
- </aside>
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 { ExternalEmbed } from "./ExternalEmbed";
16
- import { WidgetChart } from "./WidgetChart";
17
- import { IntentBlock, UiBlock, UiErrorBlock } from "./UiBlock";
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", "language-embed", "language-widget", "language-ui", "language-ui-error", "language-intent"];
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
- const calloutKinds: Record<string, { tone: "info" | "success" | "warning" | "danger" }> = {
58
- NOTE: { tone: "info" }, INFO: { tone: "info" },
59
- TIP: { tone: "success" }, SUCCESS: { tone: "success" },
60
- IMPORTANT: { tone: "warning" }, WARNING: { tone: "warning" },
61
- CAUTION: { tone: "danger" }, DANGER: { tone: "danger" }, FAILURE: { tone: "danger" },
62
- };
63
-
64
- const automaticCalloutLabels = new Set([
65
- "提示", "说明", "建议", "成功", "重要",
66
- "告警", "警告", "警示", "危险", "失败",
67
- ]);
68
-
69
- export function normalizeCallouts(markdown: string) {
70
- let fence: string | null = null;
71
- let pendingLegacyLabel = false;
72
- return markdown.split("\n").flatMap((line) => {
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 (pendingLegacyLabel && /^>\s*$/u.test(line)) return [line];
82
- if (pendingLegacyLabel) {
83
- pendingLegacyLabel = false;
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
- const match = line.match(/^>\s*\[!([a-z]+)\]\s*(.*)$/iu);
88
- const callout = match ? calloutKinds[match[1].toUpperCase()] : undefined;
89
- if (!callout) return [line];
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?.startsWith("http");
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.only(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
- <pre {...preProps} data-language={language?.toUpperCase()}>
141
- {children}
142
- </pre>
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
- {normalizeCallouts(content)}
226
+ {content}
148
227
  </ReactMarkdown>
149
228
  </div>
150
229
  );