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.
- package/app/blog/[...path]/page.tsx +2 -2
- package/app/blog/page.tsx +5 -2
- package/app/columns/[column]/[...path]/page.tsx +2 -6
- package/app/components/ArticleReferences.tsx +31 -31
- package/app/components/BlogArchive.tsx +20 -28
- package/app/components/CoverImage.tsx +34 -0
- package/app/components/LinkCardRefresh.tsx +44 -0
- package/app/components/MarkdownArticle.tsx +87 -19
- package/app/components/SiteHeader.tsx +104 -28
- package/app/garden-modules.d.ts +17 -1
- package/app/globals.css +75 -37
- package/app/layout.tsx +1 -7
- package/app/lib/archive.ts +43 -0
- package/app/lib/content.ts +5 -39
- package/app/lib/generated-content.ts +1 -1
- package/app/lib/heading-number.ts +9 -0
- package/app/lib/remark-link-card.mjs +83 -0
- package/app/lib/tag-filter.ts +52 -15
- package/app/page.tsx +3 -3
- package/app/rss.xml/route.ts +2 -1
- package/app/styles/a2.css +63 -12
- package/bin/garden.mjs +6 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +47 -6
- package/scripts/cover-images.mjs +69 -0
- package/scripts/link-assets.mjs +271 -0
- package/scripts/link-meta-core.mjs +321 -0
- package/ui/core/transform.mjs +4 -3
- package/ui/garden/card-refresh.mjs +92 -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/preview/dist/garden-ui.esm.js +3 -3
- package/ui/preview/dist/garden-ui.iife.js +2 -2
- 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
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 {
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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((
|
|
171
|
-
<li key={slug}>
|
|
172
|
-
<Link href={columnIndexHref(slug)}><span>{
|
|
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
|
-
|
|
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
|
}
|