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.
Files changed (58) hide show
  1. package/app/a2/[slug]/page.tsx +61 -0
  2. package/app/a2/page.tsx +27 -0
  3. package/app/blog/[...path]/page.tsx +33 -22
  4. package/app/blog/page.tsx +6 -7
  5. package/app/columns/[column]/[...path]/page.tsx +33 -25
  6. package/app/columns/page.tsx +38 -52
  7. package/app/components/ArticleMeasure.tsx +95 -0
  8. package/app/components/ArticleTitle.tsx +20 -0
  9. package/app/components/BlogArchive.tsx +106 -102
  10. package/app/components/MarkdownArticle.tsx +145 -66
  11. package/app/components/MermaidDiagram.tsx +44 -71
  12. package/app/components/SiteFooter.tsx +3 -7
  13. package/app/components/SiteHeader.tsx +55 -41
  14. package/app/components/TableOfContents.tsx +3 -2
  15. package/app/components/TagIndex.tsx +97 -0
  16. package/app/components/garden/GardenBlock.tsx +45 -0
  17. package/app/components/garden/blocks.tsx +530 -0
  18. package/app/components/garden/chrome.tsx +350 -0
  19. package/app/components/garden/sandbox-message.ts +16 -0
  20. package/app/garden-modules.d.ts +2 -0
  21. package/app/globals.css +17 -12
  22. package/app/layout.tsx +9 -0
  23. package/app/lib/a2-meta.ts +22 -0
  24. package/app/lib/a2-preview.ts +50 -0
  25. package/app/lib/content.ts +48 -5
  26. package/app/lib/markdown-plugins.ts +2 -0
  27. package/app/lib/remark-a2.mjs +208 -0
  28. package/app/lib/remark-garden.mjs +88 -0
  29. package/app/page.tsx +122 -93
  30. package/app/styles/a2.css +1383 -0
  31. package/app/styles/shell.css +438 -0
  32. package/app/styles/tokens.css +111 -0
  33. package/app/tags/page.tsx +28 -0
  34. package/app/ui-parity/page.tsx +3 -2
  35. package/package.json +1 -1
  36. package/scripts/build-content.mjs +11 -1
  37. package/scripts/check-post.mjs +18 -4
  38. package/scripts/new-post.mjs +3 -0
  39. package/site.config.yaml +1 -1
  40. package/templates/garden-blocks.md +128 -0
  41. package/templates/garden-snippets.plist +121 -0
  42. package/ui/garden/catalog.mjs +196 -0
  43. package/ui/garden/check.mjs +103 -0
  44. package/ui/garden/enhance.mjs +89 -0
  45. package/ui/garden/parse.mjs +198 -0
  46. package/ui/garden/snippets.mjs +109 -0
  47. package/ui/garden/title.mjs +37 -0
  48. package/ui/garden/word-count.mjs +266 -0
  49. package/ui/host.mjs +45 -28
  50. package/ui/parity-fixtures.mjs +1 -1
  51. package/ui/preview/dist/garden-ui.css +81 -77
  52. package/ui/preview/dist/garden-ui.esm.js +124 -89
  53. package/ui/preview/dist/garden-ui.iife.js +124 -89
  54. package/ui/prompt/rules.md +17 -1
  55. package/ui/react/CatalogPage.tsx +12 -0
  56. package/ui/react/components/display.tsx +1 -1
  57. package/ui/react/components/media.tsx +2 -2
  58. package/ui/react/ui.css +81 -77
@@ -2,124 +2,128 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { useSearchParams } from "next/navigation";
5
- import { ArrowLeft, ArrowRight, PushPin } from "@phosphor-icons/react";
6
- import { formatDate, postHref, posts, topics } from "../lib/content";
5
+ import { useState } from "react";
6
+ import { columns, postHref, posts, topics } from "../lib/content";
7
7
  import { ContentEmptyState } from "./ContentEmptyState";
8
8
 
9
- const pageSize = 8;
10
-
11
- export const archiveHref = (topic?: string, page?: number) => {
9
+ export const archiveHref = (topic?: string) => {
12
10
  const params = new URLSearchParams();
13
11
  if (topic) params.set("topic", topic);
14
- if (page && page > 1) params.set("page", String(page));
15
12
  const query = params.toString();
16
13
  return query ? `/blog?${query}` : "/blog";
17
14
  };
18
15
 
16
+ function day(value: string) {
17
+ const date = new Date(value);
18
+ if (Number.isNaN(date.getTime())) return value.slice(0, 10);
19
+ return new Intl.DateTimeFormat("en-CA", {
20
+ timeZone: "Asia/Shanghai",
21
+ year: "numeric",
22
+ month: "2-digit",
23
+ day: "2-digit",
24
+ }).format(date);
25
+ }
26
+
19
27
  export function BlogArchive() {
20
28
  const searchParams = useSearchParams();
29
+ const [compact, setCompact] = useState(false);
21
30
  const requestedTopic = searchParams.get("topic") ?? "";
22
31
  const selectedTopic = topics.includes(requestedTopic) ? requestedTopic : undefined;
23
- const filteredPosts = selectedTopic
24
- ? posts.filter((post) => post.topic === selectedTopic)
25
- : posts;
26
- const pageCount = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
27
- const parsedPage = Number.parseInt(searchParams.get("page") ?? "1", 10);
28
- const currentPage = Math.min(
29
- pageCount,
30
- Math.max(1, Number.isFinite(parsedPage) ? parsedPage : 1),
31
- );
32
- const visiblePosts = filteredPosts.slice(
33
- (currentPage - 1) * pageSize,
34
- currentPage * pageSize,
32
+ const filtered = (selectedTopic ? posts.filter((post) => post.topic === selectedTopic) : posts)
33
+ .slice()
34
+ .sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
35
+ const years = new Map<string, typeof filtered>();
36
+ for (const post of filtered) {
37
+ const year = day(post.date).slice(0, 4) || "未知";
38
+ const list = years.get(year);
39
+ if (list) list.push(post);
40
+ else years.set(year, [post]);
41
+ }
42
+
43
+ const series = Array.from(
44
+ columns.reduce((result, entry) => {
45
+ const key = entry.column ?? "";
46
+ const list = result.get(key);
47
+ if (list) list.push(entry);
48
+ else result.set(key, [entry]);
49
+ return result;
50
+ }, new Map<string, typeof columns>()),
35
51
  );
36
52
 
37
53
  return (
38
- <>
39
- <div className="topic-strip" aria-label="文章主题">
40
- <Link
41
- href="/blog"
42
- className={!selectedTopic ? "active" : ""}
43
- aria-current={!selectedTopic ? "page" : undefined}
44
- >
45
- 全部 · {posts.length}
46
- </Link>
47
- {topics.map((topic) => (
48
- <Link
49
- href={archiveHref(topic)}
50
- className={selectedTopic === topic ? "active" : ""}
51
- aria-current={selectedTopic === topic ? "page" : undefined}
52
- key={topic}
53
- >
54
- {topic} · {posts.filter((post) => post.topic === topic).length}
55
- </Link>
56
- ))}
57
- </div>
58
-
59
- {visiblePosts.length > 0 ? (
60
- <section className="archive-list" aria-label="全部博文">
61
- {visiblePosts.map((post) => (
62
- <Link
63
- key={post.path}
64
- href={postHref(post)}
65
- className={`archive-row${post.cover ? " with-cover" : ""}`}
66
- >
67
- {post.cover && (
68
- // eslint-disable-next-line @next/next/no-img-element
69
- <img
70
- className="archive-cover"
71
- src={post.cover}
72
- alt={`${post.title} 封面`}
73
- loading="lazy"
74
- />
75
- )}
76
- <div className="archive-copy">
77
- <div className="archive-meta">
78
- <time>{formatDate(post.date)}</time>
79
- <span className="archive-topic">
80
- {post.topic}
81
- {post.pinned && (
82
- <span className="pinned-badge">
83
- <PushPin size={12} weight="fill" />
84
- 置顶
85
- </span>
86
- )}
87
- </span>
88
- </div>
89
- <h2>{post.title}</h2>
90
- <p>{post.description}</p>
91
- </div>
92
- <ArrowRight size={19} />
54
+ <div className="sh-listgrid">
55
+ <div>
56
+ <ul className="sh-filters" aria-label="按分类筛选">
57
+ <li>
58
+ <Link className="sh-chip" href="/blog" aria-current={selectedTopic ? undefined : "true"}>
59
+ 全部<span className="n">{posts.length}</span>
93
60
  </Link>
61
+ </li>
62
+ {topics.map((topic) => (
63
+ <li key={topic}>
64
+ <Link className="sh-chip" href={archiveHref(topic)} aria-current={selectedTopic === topic ? "true" : undefined}>
65
+ {topic}<span className="n">{posts.filter((post) => post.topic === topic).length}</span>
66
+ </Link>
67
+ </li>
94
68
  ))}
95
- </section>
96
- ) : (
97
- <ContentEmptyState kind="posts" />
98
- )}
99
-
100
- {pageCount > 1 && (
101
- <nav className="archive-pagination" aria-label="博文分页">
102
- {currentPage > 1 ? (
103
- <Link href={archiveHref(selectedTopic, currentPage - 1)}>
104
- <ArrowLeft size={16} />
105
- 上一页
106
- </Link>
107
- ) : (
108
- <span />
109
- )}
110
- <span>
111
- {currentPage} / {pageCount}
112
- </span>
113
- {currentPage < pageCount ? (
114
- <Link href={archiveHref(selectedTopic, currentPage + 1)}>
115
- 下一页
116
- <ArrowRight size={16} />
117
- </Link>
118
- ) : (
119
- <span />
120
- )}
121
- </nav>
122
- )}
123
- </>
69
+ </ul>
70
+ <div className="sh-status">
71
+ <span aria-live="polite">{selectedTopic ? `${selectedTopic} ${filtered.length} 篇` : `全部 ${posts.length} 篇`}</span>
72
+ {selectedTopic ? <Link href="/blog">清除筛选</Link> : null}
73
+ <div className="sh-view" role="group" aria-label="列表密度">
74
+ <button type="button" aria-pressed={!compact} onClick={() => setCompact(false)}>摘要</button>
75
+ <button type="button" aria-pressed={compact} onClick={() => setCompact(true)}>紧凑</button>
76
+ </div>
77
+ </div>
78
+ <div id="sh-list" className={compact ? "is-compact" : undefined}>
79
+ {filtered.length ? [...years.entries()].map(([year, entries]) => (
80
+ <section className="sh-year" id={`y${year}`} key={year} aria-labelledby={`yh${year}`}>
81
+ <h2 id={`yh${year}`}>{year}<span className="n">{entries.length} 篇</span></h2>
82
+ <ol className="sh-rows">
83
+ {entries.map((post) => (
84
+ <li className="sh-row" key={post.path}>
85
+ <time className="archive-meta" dateTime={day(post.date)}>{day(post.date).slice(5)}</time>
86
+ <div className="archive-copy">
87
+ {post.cover ? (
88
+ // eslint-disable-next-line @next/next/no-img-element
89
+ <img className="archive-cover" src={post.cover} alt="" />
90
+ ) : null}
91
+ <h3>
92
+ <Link href={postHref(post)}>{post.title}</Link>
93
+ {post.pinned ? <span className="pin">置顶</span> : null}
94
+ </h3>
95
+ {post.description ? <p>{post.description}</p> : null}
96
+ </div>
97
+ {post.topic ? <Link className="cat" href={archiveHref(post.topic)}>{post.topic}</Link> : null}
98
+ </li>
99
+ ))}
100
+ </ol>
101
+ </section>
102
+ )) : <ContentEmptyState kind="posts" />}
103
+ </div>
104
+ </div>
105
+ <aside className="sh-rail" aria-label="按年份跳转">
106
+ <h2>按年份</h2>
107
+ <ol>
108
+ {[...years.keys()].map((year) => (
109
+ <li key={year}>
110
+ <a href={`#y${year}`}><span>{year}</span><span className="n num">{years.get(year)?.length ?? 0}</span></a>
111
+ </li>
112
+ ))}
113
+ </ol>
114
+ {series.length ? (
115
+ <>
116
+ <h2>专栏</h2>
117
+ <ol>
118
+ {series.map(([slug, entries]) => (
119
+ <li key={slug}>
120
+ <Link href={`/columns#${slug}`}><span>{entries[0]?.columnTitle ?? slug}</span><span className="n num">{entries.length}</span></Link>
121
+ </li>
122
+ ))}
123
+ </ol>
124
+ </>
125
+ ) : null}
126
+ </aside>
127
+ </div>
124
128
  );
125
129
  }
@@ -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
  );