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.
- package/.agents/skills/garden-interactive-chart/SKILL.md +5 -5
- package/Makefile +2 -3
- package/app/a2/[slug]/page.tsx +0 -1
- package/app/a2/page.tsx +1 -1
- package/app/about/page.tsx +8 -12
- package/app/admin/page.tsx +2 -2
- package/app/blog/[...path]/page.tsx +4 -14
- package/app/blog/page.tsx +6 -3
- package/app/columns/[column]/[...path]/page.tsx +4 -9
- package/app/columns/page.tsx +2 -2
- package/app/components/AdminDashboard.tsx +3 -3
- package/app/components/ArticleMeasure.tsx +114 -42
- package/app/components/ArticleReferences.tsx +31 -31
- package/app/components/ArticleTags.tsx +17 -0
- package/app/components/BlogArchive.tsx +94 -50
- package/app/components/ContentEmptyState.tsx +6 -6
- package/app/components/CoverImage.tsx +34 -0
- package/app/components/GardenAdminGuide.tsx +1 -1
- package/app/components/GardenContentGuide.tsx +1 -2
- package/app/components/GardenMediaGuide.tsx +3 -3
- package/app/components/GithubProfile.tsx +1 -1
- package/app/components/LinkCardRefresh.tsx +44 -0
- package/app/components/MarkdownArticle.tsx +87 -19
- package/app/components/MarkdownImage.tsx +4 -2
- package/app/components/SiteFooter.tsx +5 -5
- package/app/components/SiteHeader.tsx +105 -33
- package/app/components/TagRedirect.tsx +17 -0
- package/app/garden/page.tsx +6 -6
- package/app/garden-modules.d.ts +17 -2
- package/app/globals.css +92 -136
- package/app/layout.tsx +1 -7
- package/app/lib/a2-meta.ts +10 -0
- package/app/lib/a2-preview.ts +0 -2
- package/app/lib/archive.ts +43 -0
- package/app/lib/content.ts +12 -43
- package/app/lib/fallback-site-config.ts +15 -15
- package/app/lib/generated-content.ts +58 -47
- package/app/lib/heading-number.ts +9 -0
- package/app/lib/remark-link-card.mjs +83 -0
- package/app/lib/tag-filter.ts +151 -0
- package/app/not-found.tsx +13 -0
- package/app/page.tsx +12 -29
- package/app/rss.xml/route.ts +2 -1
- package/app/styles/a2.css +159 -27
- package/app/styles/shell.css +54 -59
- package/app/styles/tokens.css +35 -22
- package/app/tags/page.tsx +20 -9
- package/bin/garden.mjs +6 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +50 -9
- package/scripts/check-post.mjs +0 -3
- package/scripts/cover-images.mjs +69 -0
- package/scripts/link-assets.mjs +271 -0
- package/scripts/link-meta-core.mjs +321 -0
- package/scripts/new-post.mjs +1 -3
- package/site.config.yaml +15 -15
- package/templates/site/Makefile +2 -3
- package/templates/site/site.config.yaml +15 -15
- package/ui/core/transform.mjs +4 -3
- package/ui/garden/card-refresh.mjs +92 -0
- package/ui/garden/check.mjs +2 -4
- package/ui/garden/enhance.mjs +57 -0
- package/ui/garden/markdown-links.mjs +84 -0
- package/ui/garden/references.mjs +21 -0
- package/ui/garden/search-full.mjs +131 -0
- package/ui/garden/search-index.mjs +102 -0
- package/ui/garden/search-match.mjs +96 -0
- package/ui/garden/title.mjs +5 -8
- package/ui/host.mjs +13 -13
- package/ui/preview/dist/garden-ui.css +11 -11
- package/ui/preview/dist/garden-ui.esm.js +8 -8
- package/ui/preview/dist/garden-ui.iife.js +8 -8
- package/ui/react/components/media.tsx +2 -2
- package/ui/react/ui.css +11 -11
- package/worker/og/README.md +59 -0
- package/worker/og/handler.mjs +147 -0
- package/worker/og/index.mjs +61 -0
- package/worker/og/wrangler.toml +18 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
31
|
-
const
|
|
32
|
-
const
|
|
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
|
-
|
|
57
|
-
<
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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">{
|
|
72
|
-
{
|
|
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
|
-
|
|
131
|
+
<ColumnLabel column={post.column} />
|
|
98
132
|
</li>
|
|
99
133
|
))}
|
|
100
134
|
</ol>
|
|
101
135
|
</section>
|
|
102
|
-
)) :
|
|
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((
|
|
119
|
-
<li key={slug}>
|
|
120
|
-
<Link href={
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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"
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
<
|
|
84
|
-
<
|
|
85
|
-
<
|
|
86
|
-
<span className="lc-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
{...
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
14
|
-
<GithubLogo size={
|
|
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"
|
|
18
|
-
<Rss size={
|
|
17
|
+
<a href="/rss.xml">
|
|
18
|
+
<Rss size={16} aria-hidden="true" />
|
|
19
19
|
RSS
|
|
20
20
|
</a>
|
|
21
21
|
</div>
|