hd-garden 0.5.0 → 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 (40) hide show
  1. package/app/blog/[...path]/page.tsx +2 -2
  2. package/app/blog/page.tsx +5 -2
  3. package/app/columns/[column]/[...path]/page.tsx +2 -6
  4. package/app/components/ArticleReferences.tsx +31 -31
  5. package/app/components/BlogArchive.tsx +20 -28
  6. package/app/components/CoverImage.tsx +34 -0
  7. package/app/components/LinkCardRefresh.tsx +44 -0
  8. package/app/components/MarkdownArticle.tsx +87 -19
  9. package/app/components/SiteHeader.tsx +104 -28
  10. package/app/garden-modules.d.ts +17 -1
  11. package/app/globals.css +75 -37
  12. package/app/layout.tsx +1 -7
  13. package/app/lib/archive.ts +43 -0
  14. package/app/lib/content.ts +5 -39
  15. package/app/lib/generated-content.ts +1 -1
  16. package/app/lib/heading-number.ts +9 -0
  17. package/app/lib/remark-link-card.mjs +83 -0
  18. package/app/lib/tag-filter.ts +52 -15
  19. package/app/page.tsx +3 -3
  20. package/app/rss.xml/route.ts +2 -1
  21. package/app/styles/a2.css +63 -12
  22. package/bin/garden.mjs +6 -0
  23. package/package.json +1 -1
  24. package/scripts/build-content.mjs +47 -6
  25. package/scripts/cover-images.mjs +69 -0
  26. package/scripts/link-assets.mjs +271 -0
  27. package/scripts/link-meta-core.mjs +321 -0
  28. package/ui/core/transform.mjs +4 -3
  29. package/ui/garden/card-refresh.mjs +92 -0
  30. package/ui/garden/markdown-links.mjs +84 -0
  31. package/ui/garden/references.mjs +21 -0
  32. package/ui/garden/search-full.mjs +131 -0
  33. package/ui/garden/search-index.mjs +102 -0
  34. package/ui/garden/search-match.mjs +96 -0
  35. package/ui/preview/dist/garden-ui.esm.js +3 -3
  36. package/ui/preview/dist/garden-ui.iife.js +2 -2
  37. package/worker/og/README.md +59 -0
  38. package/worker/og/handler.mjs +147 -0
  39. package/worker/og/index.mjs +61 -0
  40. package/worker/og/wrangler.toml +18 -0
@@ -1,4 +1,5 @@
1
1
  import Link from "next/link";
2
+ import { CoverImage } from "../../components/CoverImage";
2
3
  import type { Metadata } from "next";
3
4
  import { notFound } from "next/navigation";
4
5
  import {
@@ -110,8 +111,7 @@ export default async function PostPage({
110
111
  <ArticleTags tags={post.tags} />
111
112
  {post.cover && (
112
113
  <figure className="article-hero article-cover cover-card cover-img">
113
- {/* eslint-disable-next-line @next/next/no-img-element */}
114
- <img className="u-photo" src={post.cover} alt={`${post.title} 封面`} />
114
+ <CoverImage className="u-photo" src={post.cover} image={post.coverImage} alt={`${post.title} 封面`} sizes="(max-width: 920px) calc(100vw - 40px), 880px" priority />
115
115
  </figure>
116
116
  )}
117
117
  </header>
package/app/blog/page.tsx CHANGED
@@ -2,6 +2,7 @@ import { Suspense } from "react";
2
2
  import { siteConfig, siteDocumentTitle } from "../site.config";
3
3
  import { BlogArchive, BlogArchiveFallback } from "../components/BlogArchive";
4
4
  import { posts } from "../lib/content";
5
+ import { archivePosts, archiveSeries } from "../lib/archive";
5
6
 
6
7
  export const metadata = {
7
8
  title: siteDocumentTitle,
@@ -9,14 +10,16 @@ export const metadata = {
9
10
  };
10
11
 
11
12
  export default function BlogIndex() {
13
+ const list = archivePosts();
14
+ const series = archiveSeries();
12
15
  return (
13
16
  <main className="sh-main" id="content">
14
17
  <div className="sh-head">
15
18
  <h1 id="sh-title">文章</h1>
16
19
  <p className="sh-deck">阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</p>
17
20
  </div>
18
- <Suspense fallback={<BlogArchiveFallback />}>
19
- <BlogArchive />
21
+ <Suspense fallback={<BlogArchiveFallback posts={list} series={series} />}>
22
+ <BlogArchive posts={list} series={series} />
20
23
  </Suspense>
21
24
  </main>
22
25
  );
@@ -1,4 +1,5 @@
1
1
  import Link from "next/link";
2
+ import { CoverImage } from "../../../components/CoverImage";
2
3
  import type { Metadata } from "next";
3
4
  import { notFound } from "next/navigation";
4
5
  import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
@@ -130,12 +131,7 @@ export default async function ColumnEntryPage({
130
131
  <ArticleTags tags={entry.tags} />
131
132
  {entry.cover && (
132
133
  <figure className="article-hero article-cover cover-img">
133
- {/* eslint-disable-next-line @next/next/no-img-element */}
134
- <img
135
- className="u-photo"
136
- src={entry.cover}
137
- alt={`${entry.title} 封面`}
138
- />
134
+ <CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={`${entry.title} 封面`} sizes="(max-width: 920px) calc(100vw - 40px), 880px" priority />
139
135
  </figure>
140
136
  )}
141
137
  </header>
@@ -1,6 +1,9 @@
1
+ import { referenceColumns, referenceDomain } from "../../ui/garden/references.mjs";
2
+
1
3
  type ReferenceItem = {
2
4
  title: string;
3
5
  url?: string;
6
+ domain?: string;
4
7
  };
5
8
 
6
9
  export function ArticleReferences({
@@ -9,39 +12,36 @@ export function ArticleReferences({
9
12
  references?: ReferenceItem[];
10
13
  }) {
11
14
  if (!references?.length) return null;
15
+ const split = referenceColumns(references.length) === 2;
12
16
 
13
17
  return (
14
- <section className="article-references" aria-labelledby="article-references-heading">
15
- <p id="article-references-heading">参考</p>
16
- <table>
17
- <tbody>
18
- {references.map((item, index) => {
19
- const number = index + 1;
20
- return (
21
- <tr key={index}>
22
- <td>
23
- {item.url ? (
24
- <a href={item.url} tabIndex={-1} aria-hidden="true">
25
- {number}
26
- </a>
27
- ) : (
28
- <span>{number}</span>
29
- )}
30
- </td>
31
- <td>
32
- {item.url ? (
33
- <a href={item.url} rel="noreferrer">
34
- <span>{item.title}</span>
35
- </a>
36
- ) : (
37
- <span>{item.title}</span>
38
- )}
39
- </td>
40
- </tr>
41
- );
42
- })}
43
- </tbody>
44
- </table>
18
+ <section
19
+ className={split ? "article-references is-split" : "article-references"}
20
+ aria-labelledby="article-references-heading"
21
+ >
22
+ <h2 id="article-references-heading">参考</h2>
23
+ <ol>
24
+ {references.map((item, index) => {
25
+ const domain = item.domain ?? referenceDomain(item.url);
26
+ return (
27
+ <li key={index}>
28
+ {item.url ? (
29
+ <a href={item.url} rel="noreferrer">
30
+ {item.title}
31
+ </a>
32
+ ) : (
33
+ <span className="ref-title">{item.title}</span>
34
+ )}
35
+ {domain ? (
36
+ <>
37
+ {" "}
38
+ <span className="ref-domain">{domain}</span>
39
+ </>
40
+ ) : null}
41
+ </li>
42
+ );
43
+ })}
44
+ </ol>
45
45
  </section>
46
46
  );
47
47
  }
@@ -3,9 +3,10 @@
3
3
  import Link from "next/link";
4
4
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
5
5
  import { useMemo, useState } from "react";
6
- import { columnIndexHref, columnOfPost, columns, postHref, posts } from "../lib/content";
6
+ import type { ArchivePost, ArchiveSeries } from "../lib/archive";
7
7
  import {
8
8
  TAG_PARAM,
9
+ canonicalTags,
9
10
  filterByTags,
10
11
  selectedTags,
11
12
  tagFilterHref,
@@ -27,35 +28,40 @@ function day(value: string) {
27
28
  }).format(date);
28
29
  }
29
30
 
30
- function ColumnLabel({ path }: { path: string }) {
31
- const column = columnOfPost({ path });
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"] }) {
32
36
  return column ? <Link className="cat" href={columnIndexHref(column.slug)}>{column.title}</Link> : null;
33
37
  }
34
38
 
39
+ type ArchiveData = { posts: ArchivePost[]; series: ArchiveSeries[] };
40
+
35
41
  const NO_TAGS: string[] = [];
36
42
 
37
43
  /** Reads the selected tags from the URL; needs a Suspense boundary. */
38
- export function BlogArchive() {
44
+ export function BlogArchive({ posts, series }: ArchiveData) {
39
45
  const router = useRouter();
40
46
  const pathname = usePathname() || "/blog";
41
47
  const searchParams = useSearchParams();
42
48
  const paramKey = searchParams.getAll(TAG_PARAM).join("\u0000");
43
- const selected = useMemo(() => selectedTags(paramKey ? paramKey.split("\u0000") : []), [paramKey]);
44
- return <ArchiveView selected={selected} go={(next) => router.push(tagFilterHref(next, pathname), { scroll: false })} />;
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 })} />;
45
51
  }
46
52
 
47
53
  /**
48
54
  * The full, unfiltered list. Rendered into the static page as the Suspense
49
55
  * fallback, so the list is there before scripts run (or without them).
50
56
  */
51
- export function BlogArchiveFallback() {
52
- return <ArchiveView selected={NO_TAGS} go={(next) => window.location.assign(tagFilterHref(next))} />;
57
+ export function BlogArchiveFallback({ posts, series }: ArchiveData) {
58
+ return <ArchiveView posts={posts} series={series} selected={NO_TAGS} go={(next) => window.location.assign(tagFilterHref(next))} />;
53
59
  }
54
60
 
55
- function ArchiveView({ selected, go }: { selected: string[]; go: (next: string[]) => void }) {
61
+ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected: string[]; go: (next: string[]) => void }) {
56
62
  const [compact, setCompact] = useState(false);
57
63
  const [expanded, setExpanded] = useState(false);
58
- const options = useMemo(() => tagOptions(posts, selected), [selected]);
64
+ const options = useMemo(() => tagOptions(posts, selected), [posts, selected]);
59
65
  const { shown, hidden } = visibleTagOptions(options, expanded);
60
66
  const filtered = filterByTags(posts, selected)
61
67
  .slice()
@@ -68,16 +74,6 @@ function ArchiveView({ selected, go }: { selected: string[]; go: (next: string[]
68
74
  else years.set(year, [post]);
69
75
  }
70
76
 
71
- const series = Array.from(
72
- columns.reduce((result, entry) => {
73
- const key = entry.column ?? "";
74
- const list = result.get(key);
75
- if (list) list.push(entry);
76
- else result.set(key, [entry]);
77
- return result;
78
- }, new Map<string, typeof columns>()),
79
- );
80
-
81
77
  return (
82
78
  <div className="sh-listgrid">
83
79
  <div>
@@ -126,17 +122,13 @@ function ArchiveView({ selected, go }: { selected: string[]; go: (next: string[]
126
122
  <li className="sh-row" key={post.path}>
127
123
  <time className="archive-meta" dateTime={day(post.date)}>{day(post.date).slice(5)}</time>
128
124
  <div className="archive-copy">
129
- {post.cover ? (
130
- // eslint-disable-next-line @next/next/no-img-element
131
- <img className="archive-cover" src={post.cover} alt="" />
132
- ) : null}
133
125
  <h3>
134
126
  <Link href={postHref(post)}>{post.title}</Link>
135
127
  {post.pinned ? <span className="pin">置顶</span> : null}
136
128
  </h3>
137
129
  {post.description ? <p>{post.description}</p> : null}
138
130
  </div>
139
- <ColumnLabel path={post.path} />
131
+ <ColumnLabel column={post.column} />
140
132
  </li>
141
133
  ))}
142
134
  </ol>
@@ -167,9 +159,9 @@ function ArchiveView({ selected, go }: { selected: string[]; go: (next: string[]
167
159
  <>
168
160
  <h2>专栏</h2>
169
161
  <ol>
170
- {series.map(([slug, entries]) => (
171
- <li key={slug}>
172
- <Link href={columnIndexHref(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>
173
165
  </li>
174
166
  ))}
175
167
  </ol>
@@ -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
+ }
@@ -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
  }