hd-garden 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.agents/skills/garden-interactive-chart/SKILL.md +5 -5
  2. package/Makefile +2 -3
  3. package/app/a2/[slug]/page.tsx +0 -1
  4. package/app/a2/page.tsx +1 -1
  5. package/app/about/page.tsx +8 -12
  6. package/app/admin/page.tsx +2 -2
  7. package/app/blog/[...path]/page.tsx +4 -14
  8. package/app/blog/page.tsx +6 -3
  9. package/app/columns/[column]/[...path]/page.tsx +4 -9
  10. package/app/columns/page.tsx +2 -2
  11. package/app/components/AdminDashboard.tsx +3 -3
  12. package/app/components/ArticleMeasure.tsx +114 -42
  13. package/app/components/ArticleReferences.tsx +31 -31
  14. package/app/components/ArticleTags.tsx +17 -0
  15. package/app/components/BlogArchive.tsx +94 -50
  16. package/app/components/ContentEmptyState.tsx +6 -6
  17. package/app/components/CoverImage.tsx +34 -0
  18. package/app/components/GardenAdminGuide.tsx +1 -1
  19. package/app/components/GardenContentGuide.tsx +1 -2
  20. package/app/components/GardenMediaGuide.tsx +3 -3
  21. package/app/components/GithubProfile.tsx +1 -1
  22. package/app/components/LinkCardRefresh.tsx +44 -0
  23. package/app/components/MarkdownArticle.tsx +87 -19
  24. package/app/components/MarkdownImage.tsx +4 -2
  25. package/app/components/SiteFooter.tsx +5 -5
  26. package/app/components/SiteHeader.tsx +105 -33
  27. package/app/components/TagRedirect.tsx +17 -0
  28. package/app/garden/page.tsx +6 -6
  29. package/app/garden-modules.d.ts +17 -2
  30. package/app/globals.css +92 -136
  31. package/app/layout.tsx +1 -7
  32. package/app/lib/a2-meta.ts +10 -0
  33. package/app/lib/a2-preview.ts +0 -2
  34. package/app/lib/archive.ts +43 -0
  35. package/app/lib/content.ts +12 -43
  36. package/app/lib/fallback-site-config.ts +15 -15
  37. package/app/lib/generated-content.ts +58 -47
  38. package/app/lib/heading-number.ts +9 -0
  39. package/app/lib/remark-link-card.mjs +83 -0
  40. package/app/lib/tag-filter.ts +151 -0
  41. package/app/not-found.tsx +13 -0
  42. package/app/page.tsx +12 -29
  43. package/app/rss.xml/route.ts +2 -1
  44. package/app/styles/a2.css +159 -27
  45. package/app/styles/shell.css +54 -59
  46. package/app/styles/tokens.css +35 -22
  47. package/app/tags/page.tsx +20 -9
  48. package/bin/garden.mjs +6 -0
  49. package/package.json +1 -1
  50. package/scripts/build-content.mjs +50 -9
  51. package/scripts/check-post.mjs +0 -3
  52. package/scripts/cover-images.mjs +69 -0
  53. package/scripts/link-assets.mjs +271 -0
  54. package/scripts/link-meta-core.mjs +321 -0
  55. package/scripts/new-post.mjs +1 -3
  56. package/site.config.yaml +15 -15
  57. package/templates/site/Makefile +2 -3
  58. package/templates/site/site.config.yaml +15 -15
  59. package/ui/core/transform.mjs +4 -3
  60. package/ui/garden/card-refresh.mjs +92 -0
  61. package/ui/garden/check.mjs +2 -4
  62. package/ui/garden/enhance.mjs +57 -0
  63. package/ui/garden/markdown-links.mjs +84 -0
  64. package/ui/garden/references.mjs +21 -0
  65. package/ui/garden/search-full.mjs +131 -0
  66. package/ui/garden/search-index.mjs +102 -0
  67. package/ui/garden/search-match.mjs +96 -0
  68. package/ui/garden/title.mjs +5 -8
  69. package/ui/host.mjs +13 -13
  70. package/ui/preview/dist/garden-ui.css +11 -11
  71. package/ui/preview/dist/garden-ui.esm.js +8 -8
  72. package/ui/preview/dist/garden-ui.iife.js +8 -8
  73. package/ui/react/components/media.tsx +2 -2
  74. package/ui/react/ui.css +11 -11
  75. package/worker/og/README.md +59 -0
  76. package/worker/og/handler.mjs +147 -0
  77. package/worker/og/index.mjs +61 -0
  78. package/worker/og/wrangler.toml +18 -0
  79. package/app/components/TagIndex.tsx +0 -97
@@ -1,18 +1,22 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { useSearchParams } from "next/navigation";
5
- import { useState } from "react";
6
- import { columns, postHref, posts, topics } from "../lib/content";
4
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
5
+ import { useMemo, useState } from "react";
6
+ import type { ArchivePost, ArchiveSeries } from "../lib/archive";
7
+ import {
8
+ TAG_PARAM,
9
+ canonicalTags,
10
+ filterByTags,
11
+ selectedTags,
12
+ tagFilterHref,
13
+ tagOptions,
14
+ tagSummary,
15
+ toggleTag,
16
+ visibleTagOptions,
17
+ } from "../lib/tag-filter";
7
18
  import { ContentEmptyState } from "./ContentEmptyState";
8
19
 
9
- export const archiveHref = (topic?: string) => {
10
- const params = new URLSearchParams();
11
- if (topic) params.set("topic", topic);
12
- const query = params.toString();
13
- return query ? `/blog?${query}` : "/blog";
14
- };
15
-
16
20
  function day(value: string) {
17
21
  const date = new Date(value);
18
22
  if (Number.isNaN(date.getTime())) return value.slice(0, 10);
@@ -24,12 +28,42 @@ function day(value: string) {
24
28
  }).format(date);
25
29
  }
26
30
 
27
- export function BlogArchive() {
31
+ // Kept local: importing ../lib/content here would pull every post body into the client bundle.
32
+ const postHref = (post: Pick<ArchivePost, "path">) => `/blog/${post.path}`;
33
+ const columnIndexHref = (slug: string) => `/columns#${slug}`;
34
+
35
+ function ColumnLabel({ column }: { column?: ArchivePost["column"] }) {
36
+ return column ? <Link className="cat" href={columnIndexHref(column.slug)}>{column.title}</Link> : null;
37
+ }
38
+
39
+ type ArchiveData = { posts: ArchivePost[]; series: ArchiveSeries[] };
40
+
41
+ const NO_TAGS: string[] = [];
42
+
43
+ /** Reads the selected tags from the URL; needs a Suspense boundary. */
44
+ export function BlogArchive({ posts, series }: ArchiveData) {
45
+ const router = useRouter();
46
+ const pathname = usePathname() || "/blog";
28
47
  const searchParams = useSearchParams();
48
+ const paramKey = searchParams.getAll(TAG_PARAM).join("\u0000");
49
+ const selected = useMemo(() => canonicalTags(posts, selectedTags(paramKey ? paramKey.split("\u0000") : [])), [paramKey, posts]);
50
+ return <ArchiveView posts={posts} series={series} selected={selected} go={(next) => router.push(tagFilterHref(next, pathname), { scroll: false })} />;
51
+ }
52
+
53
+ /**
54
+ * The full, unfiltered list. Rendered into the static page as the Suspense
55
+ * fallback, so the list is there before scripts run (or without them).
56
+ */
57
+ export function BlogArchiveFallback({ posts, series }: ArchiveData) {
58
+ return <ArchiveView posts={posts} series={series} selected={NO_TAGS} go={(next) => window.location.assign(tagFilterHref(next))} />;
59
+ }
60
+
61
+ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected: string[]; go: (next: string[]) => void }) {
29
62
  const [compact, setCompact] = useState(false);
30
- const requestedTopic = searchParams.get("topic") ?? "";
31
- const selectedTopic = topics.includes(requestedTopic) ? requestedTopic : undefined;
32
- const filtered = (selectedTopic ? posts.filter((post) => post.topic === selectedTopic) : posts)
63
+ const [expanded, setExpanded] = useState(false);
64
+ const options = useMemo(() => tagOptions(posts, selected), [posts, selected]);
65
+ const { shown, hidden } = visibleTagOptions(options, expanded);
66
+ const filtered = filterByTags(posts, selected)
33
67
  .slice()
34
68
  .sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
35
69
  const years = new Map<string, typeof filtered>();
@@ -40,36 +74,40 @@ export function BlogArchive() {
40
74
  else years.set(year, [post]);
41
75
  }
42
76
 
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>()),
51
- );
52
-
53
77
  return (
54
78
  <div className="sh-listgrid">
55
79
  <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>
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>
68
- ))}
69
- </ul>
80
+ {options.length ? (
81
+ <div className="sh-tagfilter">
82
+ <ul className="sh-tagchips" aria-label="按标签筛选">
83
+ {shown.map((option) => (
84
+ <li key={option.tag}>
85
+ <button
86
+ type="button"
87
+ className="sh-tagchip"
88
+ aria-pressed={option.selected}
89
+ disabled={option.disabled}
90
+ onClick={() => go(toggleTag(selected, option.tag))}
91
+ >
92
+ <span className="label">#{option.tag}</span>
93
+ <span className="n num">{option.next}</span>
94
+ {option.selected ? <span className="x" aria-hidden="true">×</span> : null}
95
+ </button>
96
+ </li>
97
+ ))}
98
+ {hidden > 0 || expanded ? (
99
+ <li>
100
+ <button type="button" className="sh-tagmore" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
101
+ {expanded ? "收起" : `展开全部 ${options.length} 个标签`}
102
+ </button>
103
+ </li>
104
+ ) : null}
105
+ </ul>
106
+ </div>
107
+ ) : null}
70
108
  <div className="sh-status">
71
- <span aria-live="polite">{selectedTopic ? `${selectedTopic} ${filtered.length} 篇` : `全部 ${posts.length} 篇`}</span>
72
- {selectedTopic ? <Link href="/blog">清除筛选</Link> : null}
109
+ <span aria-live="polite">{tagSummary(selected.length, filtered.length)}</span>
110
+ {selected.length ? <button type="button" onClick={() => go([])}>清除</button> : null}
73
111
  <div className="sh-view" role="group" aria-label="列表密度">
74
112
  <button type="button" aria-pressed={!compact} onClick={() => setCompact(false)}>摘要</button>
75
113
  <button type="button" aria-pressed={compact} onClick={() => setCompact(true)}>紧凑</button>
@@ -84,26 +122,32 @@ export function BlogArchive() {
84
122
  <li className="sh-row" key={post.path}>
85
123
  <time className="archive-meta" dateTime={day(post.date)}>{day(post.date).slice(5)}</time>
86
124
  <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
125
  <h3>
92
126
  <Link href={postHref(post)}>{post.title}</Link>
93
127
  {post.pinned ? <span className="pin">置顶</span> : null}
94
128
  </h3>
95
129
  {post.description ? <p>{post.description}</p> : null}
96
130
  </div>
97
- {post.topic ? <Link className="cat" href={archiveHref(post.topic)}>{post.topic}</Link> : null}
131
+ <ColumnLabel column={post.column} />
98
132
  </li>
99
133
  ))}
100
134
  </ol>
101
135
  </section>
102
- )) : <ContentEmptyState kind="posts" />}
136
+ )) : selected.length ? (
137
+ <div className="sh-tagempty">
138
+ <p>没有同时带这些标签的文章。</p>
139
+ <div className="acts">
140
+ {selected.length > 1 ? (
141
+ <button type="button" onClick={() => go(selected.slice(0, -1))}>去掉「{selected[selected.length - 1]}」</button>
142
+ ) : null}
143
+ <button type="button" onClick={() => go([])}>看全部文章</button>
144
+ </div>
145
+ </div>
146
+ ) : <ContentEmptyState kind="posts" />}
103
147
  </div>
104
148
  </div>
105
149
  <aside className="sh-rail" aria-label="按年份跳转">
106
- <h2>按年份</h2>
150
+ {years.size ? <h2>按年份</h2> : null}
107
151
  <ol>
108
152
  {[...years.keys()].map((year) => (
109
153
  <li key={year}>
@@ -115,9 +159,9 @@ export function BlogArchive() {
115
159
  <>
116
160
  <h2>专栏</h2>
117
161
  <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>
162
+ {series.map((column) => (
163
+ <li key={column.slug}>
164
+ <Link href={columnIndexHref(column.slug)}><span>{column.title}</span><span className="n num">{column.count}</span></Link>
121
165
  </li>
122
166
  ))}
123
167
  </ol>
@@ -6,25 +6,25 @@ import { ArrowRight, Article, Books, Quotes } from "@phosphor-icons/react/ssr";
6
6
  const emptyContent = {
7
7
  posts: {
8
8
  icon: Article,
9
- eyebrow: "NO POSTS YET / 暂无博文",
9
+ eyebrow: "暂无博文",
10
10
  title: "暂无博文。",
11
- description: "发布文章后会在这里按主题与时间归档。",
11
+ description: "文章会按时间排在这里。",
12
12
  actionHref: "/",
13
13
  actionLabel: "返回首页",
14
14
  },
15
15
  thoughts: {
16
16
  icon: Quotes,
17
- eyebrow: "NO THOUGHTS YET / 暂无随想",
17
+ eyebrow: "暂无随想",
18
18
  title: "暂无随想。",
19
- description: "日常的碎片记录、短笔记与链接会落在这里。",
19
+ description: "短笔记和链接会在这里。",
20
20
  actionHref: "/blog",
21
21
  actionLabel: "先读博文",
22
22
  },
23
23
  columns: {
24
24
  icon: Books,
25
- eyebrow: "NO COLUMNS YET / 暂无专栏",
25
+ eyebrow: "暂无专栏",
26
26
  title: "暂无专栏。",
27
- description: "文章积累成系列后会在这里按顺序呈现。",
27
+ description: "一组文章会按顺序列在这里。",
28
28
  actionHref: "/blog",
29
29
  actionLabel: "浏览全部博文",
30
30
  },
@@ -0,0 +1,34 @@
1
+ import type { ContentEntry } from "../lib/content";
2
+
3
+ type CoverImageProps = {
4
+ src: string;
5
+ image?: ContentEntry["coverImage"];
6
+ alt: string;
7
+ className?: string;
8
+ /** How wide the image shows, for the browser to pick a size from srcset. */
9
+ sizes: string;
10
+ /** The first cover a reader sees: load it right away and early. Everything else waits until it is near. */
11
+ priority?: boolean;
12
+ };
13
+
14
+ /**
15
+ * A cover with resized WebP copies when the build made them. Width and height
16
+ * reserve the space, so nothing moves when the picture arrives.
17
+ */
18
+ export function CoverImage({ src, image, alt, className, sizes, priority = false }: CoverImageProps) {
19
+ return (
20
+ // eslint-disable-next-line @next/next/no-img-element
21
+ <img
22
+ className={className}
23
+ src={image?.src ?? src}
24
+ srcSet={image?.srcset}
25
+ sizes={image ? sizes : undefined}
26
+ width={image?.width}
27
+ height={image?.height}
28
+ alt={alt}
29
+ loading={priority ? "eager" : "lazy"}
30
+ decoding="async"
31
+ fetchPriority={priority ? "high" : undefined}
32
+ />
33
+ );
34
+ }
@@ -17,7 +17,7 @@ const adminJobs = [
17
17
  },
18
18
  {
19
19
  title: "改文章里的交互",
20
- description: "写作台把一句话收成可交互的块。组件页能看到每一种块长什么样。",
20
+ description: "写作台把一句话收成可以动手试的一块。",
21
21
  },
22
22
  ];
23
23
 
@@ -23,7 +23,6 @@ const postSource = `---
23
23
  title: 我的第一篇文章
24
24
  date: 2026-08-03 09:00:00
25
25
  description: 这篇文章记录我为什么重新开始写博客。
26
- topic: 写作
27
26
  tags: [Markdown, 博客]
28
27
  pinned: false
29
28
  cover: writing/first-post.jpg
@@ -100,7 +99,7 @@ const guides: Guide[] = [
100
99
  <>文件名就是地址。标题和日期写在开头。先存着就标成草稿。</>,
101
100
  <>图片、音频、视频和链接按顺序放进 <code>media</code>。</>,
102
101
  ],
103
- result: "它会出现在随想页和首页,并且有自己的链接。",
102
+ result: "它会出现在随想页,并且有自己的链接。",
104
103
  path: "content/thoughts/walk-after-rain.md",
105
104
  source: thoughtSource,
106
105
  language: "markdown",
@@ -5,7 +5,7 @@ const embedTypes = [
5
5
  },
6
6
  {
7
7
  title: "X / Twitter",
8
- description: "显示成一张带出处的卡片,不依赖对方的脚本。",
8
+ description: "显示成一张带出处的卡片。",
9
9
  },
10
10
  {
11
11
  title: "微信公众号",
@@ -13,7 +13,7 @@ const embedTypes = [
13
13
  },
14
14
  {
15
15
  title: "其他网站",
16
- description: "自己写标题、摘要和封面。不在构建时去抓那个网址。",
16
+ description: "自己写标题、摘要和封面。",
17
17
  },
18
18
  ];
19
19
 
@@ -53,7 +53,7 @@ export function GardenMediaGuide() {
53
53
  </ul>
54
54
 
55
55
  <p className="garden-media-boundary">
56
- 摘要写在文章里。构建时不去抓任意网址。原站打不开,文章还在。需要时用 <code>make embed URL=...</code> 拉一次,看过再贴进去。
56
+ 摘要写在文章里。原站打不开,文章还在。需要时用 <code>make embed URL=...</code> 拉一次,看过再贴进去。
57
57
  </p>
58
58
  </section>
59
59
  );
@@ -253,7 +253,7 @@ export async function GithubProfile() {
253
253
  <header>
254
254
  <div>
255
255
  <p className="eyebrow">PINNED / 置顶仓库</p>
256
- <h3 id="github-pinned-title">正在持续构建的项目</h3>
256
+ <h3 id="github-pinned-title">正在做的项目</h3>
257
257
  </div>
258
258
  <PushPin size={20} aria-hidden="true" />
259
259
  </header>
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { cardUpdates, fetchCardText, refreshUrl, safeToUpdate } from "../../ui/garden/card-refresh.mjs";
5
+
6
+ /**
7
+ * After the page is idle, ask the link-card Worker for each card's current
8
+ * text and swap it in for cards below the fold. Any failure leaves the
9
+ * build-time card as it is.
10
+ */
11
+ export function LinkCardRefresh({ endpoint }: { endpoint: string }) {
12
+ useEffect(() => {
13
+ const cards = [...document.querySelectorAll<HTMLElement>(".link-cards[data-card-url]")];
14
+ if (!cards.length || !refreshUrl(endpoint, "https://example.com/")) return;
15
+ // Aborts every request when the page goes away; each request also has its own timeout.
16
+ const controller = new AbortController();
17
+ const run = () => {
18
+ for (const card of cards) {
19
+ const target = refreshUrl(endpoint, card.dataset.cardUrl ?? "");
20
+ if (!target) continue;
21
+ fetchCardText(target, { shared: controller.signal })
22
+ .then((fresh) => {
23
+ if (!fresh || !safeToUpdate(card.getBoundingClientRect().top, window.innerHeight)) return;
24
+ const fields = new Map(
25
+ [...card.querySelectorAll<HTMLElement>("[data-field]")].map((node) => [node.dataset.field ?? "", node]),
26
+ );
27
+ const current = Object.fromEntries([...fields].map(([field, node]) => [field, node.textContent ?? ""]));
28
+ for (const [field, text] of Object.entries(cardUpdates(current, fresh))) {
29
+ const node = fields.get(field);
30
+ if (node) node.textContent = text;
31
+ }
32
+ })
33
+ .catch(() => {});
34
+ }
35
+ };
36
+ const idle = window.requestIdleCallback ?? ((callback: () => void) => window.setTimeout(callback, 1500));
37
+ const handle = idle(run);
38
+ return () => {
39
+ controller.abort();
40
+ (window.cancelIdleCallback ?? window.clearTimeout)(handle as number);
41
+ };
42
+ }, [endpoint]);
43
+ return null;
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { markdownPlugins } from "../lib/markdown-plugins";
2
2
  import { remarkA2 } from "../lib/remark-a2.mjs";
3
+ import { remarkLinkCard } from "../lib/remark-link-card.mjs";
3
4
  import {
4
5
  Children,
5
6
  cloneElement,
@@ -19,6 +20,42 @@ import { MarkdownImage } from "./MarkdownImage";
19
20
  import { CodeCopy } from "./garden/chrome";
20
21
  import { CodeDiff } from "./garden/chrome";
21
22
  import { GardenBlock } from "./garden/GardenBlock";
23
+ import { linkAssets } from "@garden/generated-content";
24
+ import { siteConfig } from "@garden/site-config";
25
+ import { LinkCardRefresh } from "./LinkCardRefresh";
26
+ import { linkHost } from "../../scripts/link-meta-core.mjs";
27
+ import { cardDate } from "../../ui/garden/card-refresh.mjs";
28
+
29
+ /** The site's favicon, downloaded at build time; nothing when there is none. */
30
+ function Favicon({ href }: { href: string }) {
31
+ const src = linkAssets?.favicons?.[linkHost(href)];
32
+ if (!src) return null;
33
+ // eslint-disable-next-line @next/next/no-img-element -- a 14px local icon, no optimisation needed
34
+ return <img className="fav" src={src} alt="" width={14} height={14} loading="lazy" decoding="async" aria-hidden="true" />;
35
+ }
36
+
37
+ /** The first word of a link (or its first CJK character) and the rest. */
38
+ const firstWord = (text: string) => {
39
+ const match = /^(\s*(?:[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]|[^\s\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+))([\s\S]*)$/u.exec(text);
40
+ return match ? [match[1], match[2]] : [text, ""];
41
+ };
42
+
43
+ /** Icon plus the link's first word on one line, so the icon never ends a line alone. */
44
+ function WithFavicon({ href, children }: { href: string; children: ReactNode }) {
45
+ const icon = <Favicon href={href} />;
46
+ if (!linkAssets?.favicons?.[linkHost(href)]) return <>{children}</>;
47
+ const nodes = Children.toArray(children);
48
+ const head = nodes[0];
49
+ if (typeof head !== "string") return <><span className="fav-keep">{icon}</span>{children}</>;
50
+ const [first, rest] = firstWord(head);
51
+ return (
52
+ <>
53
+ <span className="fav-keep">{icon}{first}</span>
54
+ {rest}
55
+ {nodes.slice(1)}
56
+ </>
57
+ );
58
+ }
22
59
 
23
60
  const BLOCK_LANGUAGES = ["language-mermaid"];
24
61
  const isBlockLanguage = (className?: string) =>
@@ -76,24 +113,43 @@ function stripPrefix(children: ReactNode, prefix: string): ReactNode {
76
113
  return walk(children);
77
114
  }
78
115
 
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 */ }
116
+
117
+ /**
118
+ * A link card. Standalone (the author's paragraph was just the link), it is the
119
+ * link. After a paragraph that keeps the link, it is a preview: out of the tab
120
+ * order and hidden from screen readers, since the link is already there.
121
+ */
122
+ export function LinkCard({ href, text, standalone = true }: { href: string; text?: string; standalone?: boolean }) {
123
+ const meta = linkAssets?.cards?.[href];
124
+ const host = linkHost(href) || href;
125
+ const icon = linkAssets?.favicons?.[host];
126
+ const title = meta?.title || text || href;
127
+ const date = cardDate(meta?.publishedTime);
128
+ const preview = standalone ? {} : { tabIndex: -1, "aria-hidden": true as const };
82
129
  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>
130
+ <div className="link-cards" data-card-url={href}>
131
+ <a className="link-card" href={href} target="_blank" rel="noopener noreferrer" {...preview}>
132
+ <span className="lc-body">
133
+ <span className="lc-site">
134
+ {/* eslint-disable-next-line @next/next/no-img-element -- local 14px icon */}
135
+ {icon ? <img className="fav" src={icon} alt="" width={14} height={14} loading="lazy" decoding="async" /> : null}
136
+ <span className="lc-sitename" data-field="siteName">{meta?.siteName || host}</span>
137
+ {meta?.siteName && meta.siteName.toLowerCase() !== host ? <span className="lc-host">{host}</span> : null}
138
+ <span className="lc-arrow" aria-hidden="true" />
93
139
  </span>
94
- </a>
95
- </li>
96
- </ul>
140
+ <span className="lc-title" data-field="title">{title}</span>
141
+ {meta?.description ? <span className="lc-desc" data-field="description">{meta.description}</span> : null}
142
+ {meta?.author || date ? (
143
+ <span className="lc-meta">
144
+ {meta?.author ? <span data-field="author">{meta.author}</span> : null}
145
+ {date ? <time data-field="publishedTime" dateTime={meta?.publishedTime}>{date}</time> : null}
146
+ </span>
147
+ ) : null}
148
+ </span>
149
+ {/* eslint-disable-next-line @next/next/no-img-element -- copied into the site at build time */}
150
+ {meta?.image ? <img className="lc-thumb" src={meta.image} alt="" width={168} height={88} loading="lazy" decoding="async" /> : null}
151
+ </a>
152
+ </div>
97
153
  );
98
154
  }
99
155
 
@@ -109,6 +165,8 @@ const headingText = (children: ReactNode) => {
109
165
  };
110
166
 
111
167
  export function MarkdownArticle({ content }: { content: string }) {
168
+ // Optional layer 2: a Worker that refreshes card text. Without it, cards stay as built.
169
+ const cardWorker = content.includes("{card}") ? siteConfig.linkPreview?.worker : undefined;
112
170
  const numbers = { h2: 0, h3: 0 };
113
171
  const numberedArticle = articleUsesHeadingNumbers(content);
114
172
  const headingBase = articleHeadingBase(content);
@@ -136,6 +194,13 @@ export function MarkdownArticle({ content }: { content: string }) {
136
194
  return <Tag {...domProps}>{sectioned.no ? <span className="sec-no">{sectioned.no}</span> : null}{sectioned.children}</Tag>;
137
195
  };
138
196
  const gardenComponents = {
197
+ "garden-linkcard": (props: Record<string, string | undefined>) => (
198
+ <LinkCard
199
+ href={props.dataUrl ?? props["data-url"] ?? ""}
200
+ text={props.dataText ?? props["data-text"]}
201
+ standalone={(props.dataStandalone ?? props["data-standalone"]) === "true"}
202
+ />
203
+ ),
139
204
  "garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => (
140
205
  <GardenBlock dataPayload={props.dataPayload ?? props["data-payload"]} />
141
206
  ),
@@ -156,7 +221,7 @@ export function MarkdownArticle({ content }: { content: string }) {
156
221
  return (
157
222
  <div className="markdown-body prose">
158
223
  <ReactMarkdown
159
- remarkPlugins={[...markdownPlugins, remarkA2]}
224
+ remarkPlugins={[...markdownPlugins, remarkLinkCard, remarkA2]}
160
225
  rehypePlugins={[
161
226
  rehypeRaw,
162
227
  rehypeKatex,
@@ -177,15 +242,17 @@ export function MarkdownArticle({ content }: { content: string }) {
177
242
  },
178
243
  a: ({ href, children, ...props }) => {
179
244
  const external = Boolean(href && /^(https?:)?\/\//iu.test(href));
245
+ const domProps = { ...props };
246
+ Reflect.deleteProperty(domProps, "node");
180
247
  return (
181
248
  <a
182
249
  href={href}
183
- {...props}
250
+ {...domProps}
184
251
  {...(external
185
252
  ? { className: "ext", target: "_blank", rel: "noopener noreferrer" }
186
253
  : {})}
187
254
  >
188
- {children}
255
+ {external && href ? <WithFavicon href={href}>{children}</WithFavicon> : children}
189
256
  </a>
190
257
  );
191
258
  },
@@ -225,6 +292,7 @@ export function MarkdownArticle({ content }: { content: string }) {
225
292
  >
226
293
  {content}
227
294
  </ReactMarkdown>
295
+ {cardWorker ? <LinkCardRefresh endpoint={cardWorker} /> : null}
228
296
  </div>
229
297
  );
230
298
  }
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
4
4
 
5
- type MarkdownImageProps = ComponentPropsWithoutRef<"img">;
5
+ // react-markdown also hands over its syntax-tree `node`; it must not reach the DOM.
6
+ type MarkdownImageProps = ComponentPropsWithoutRef<"img"> & { node?: unknown };
6
7
 
7
- export function MarkdownImage({ alt = "", src, ...props }: MarkdownImageProps) {
8
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
9
+ export function MarkdownImage({ alt = "", src, node, ...props }: MarkdownImageProps) {
8
10
  const [open, setOpen] = useState(false);
9
11
  const description = alt || "查看原图";
10
12
 
@@ -9,13 +9,13 @@ export function SiteFooter() {
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
- <div>
13
- <a href={githubUrl} aria-label="GitHub" rel="me">
14
- <GithubLogo size={19} />
12
+ <div className="sh-foot-links">
13
+ <a href={githubUrl} rel="me">
14
+ <GithubLogo size={16} aria-hidden="true" />
15
15
  GitHub
16
16
  </a>
17
- <a href="/rss.xml" aria-label="RSS 订阅">
18
- <Rss size={18} />
17
+ <a href="/rss.xml">
18
+ <Rss size={16} aria-hidden="true" />
19
19
  RSS
20
20
  </a>
21
21
  </div>