hd-garden 0.10.0 → 0.10.2
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/about/page.tsx +1 -1
- package/app/blog/[...path]/page.tsx +11 -80
- package/app/blog/page.tsx +5 -4
- package/app/columns/[column]/[...path]/page.tsx +31 -93
- package/app/columns/page.tsx +6 -4
- package/app/components/HandbookView.tsx +6 -6
- package/app/components/MarkdownArticle.tsx +10 -3
- package/app/components/PageIntro.tsx +9 -4
- package/app/components/PostReadingView.tsx +133 -0
- package/app/components/SeriesEdgeNav.tsx +119 -0
- package/app/layout.tsx +3 -3
- package/app/lib/i18n.ts +6 -2
- package/app/lib/remark-link-card.mjs +11 -12
- package/app/page.tsx +3 -1
- package/app/styles/a2.css +204 -0
- package/app/thoughts/page/[n]/page.tsx +1 -1
- package/app/thoughts/page.tsx +1 -1
- package/docs/handbook/components/link-card.md +5 -5
- package/docs/handbook/en/components/link-card.md +5 -5
- package/package.json +1 -1
- package/public/og.png +0 -0
- package/scripts/build-content.mjs +2 -3
- package/scripts/link-assets.mjs +7 -5
- package/scripts/link-meta-core.mjs +40 -3
- package/ui/garden/markdown-links.mjs +44 -8
- package/ui/garden/site-nav.mjs +3 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
export type SeriesEdgeItem = {
|
|
14
|
+
path: string;
|
|
15
|
+
href: string;
|
|
16
|
+
title: string;
|
|
17
|
+
current?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Left-edge Raschka ticks for column articles.
|
|
22
|
+
* Flyout is the article list only (no column chrome).
|
|
23
|
+
* Opens on hover or click; stays open until click-outside or Escape.
|
|
24
|
+
*/
|
|
25
|
+
export function SeriesEdgeNav({
|
|
26
|
+
columnTitle,
|
|
27
|
+
items,
|
|
28
|
+
}: {
|
|
29
|
+
columnTitle: string;
|
|
30
|
+
items: SeriesEdgeItem[];
|
|
31
|
+
/** @deprecated kept optional so callers can pass it; not shown in the flyout */
|
|
32
|
+
statusLabel?: string;
|
|
33
|
+
lang?: string;
|
|
34
|
+
}) {
|
|
35
|
+
const panelId = useId();
|
|
36
|
+
const rootRef = useRef<HTMLElement>(null);
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const currentIndex = Math.max(0, items.findIndex((item) => item.current));
|
|
39
|
+
|
|
40
|
+
const close = useCallback(() => setOpen(false), []);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return;
|
|
44
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
45
|
+
const root = rootRef.current;
|
|
46
|
+
if (!root) return;
|
|
47
|
+
if (event.target instanceof Node && root.contains(event.target)) return;
|
|
48
|
+
close();
|
|
49
|
+
};
|
|
50
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
51
|
+
if (event.key === "Escape") close();
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
54
|
+
document.addEventListener("keydown", onKeyDown);
|
|
55
|
+
return () => {
|
|
56
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
57
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
58
|
+
};
|
|
59
|
+
}, [close, open]);
|
|
60
|
+
|
|
61
|
+
const onTicksKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
62
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
setOpen((value) => !value);
|
|
65
|
+
} else if (event.key === "Escape") {
|
|
66
|
+
close();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<nav
|
|
72
|
+
ref={rootRef}
|
|
73
|
+
className="series-edge"
|
|
74
|
+
aria-label={columnTitle}
|
|
75
|
+
data-open={open ? "true" : "false"}
|
|
76
|
+
>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className="series-edge-ticks"
|
|
80
|
+
aria-controls={panelId}
|
|
81
|
+
aria-expanded={open}
|
|
82
|
+
aria-label={columnTitle}
|
|
83
|
+
onPointerEnter={() => setOpen(true)}
|
|
84
|
+
onFocus={() => setOpen(true)}
|
|
85
|
+
onClick={() => setOpen(true)}
|
|
86
|
+
onKeyDown={onTicksKeyDown}
|
|
87
|
+
>
|
|
88
|
+
{items.map((item, index) => (
|
|
89
|
+
<i
|
|
90
|
+
key={item.path}
|
|
91
|
+
className={index === currentIndex ? "hot" : undefined}
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</button>
|
|
96
|
+
{/* Hit bridge so the pointer can cross ticks → panel without a dead zone. */}
|
|
97
|
+
<div
|
|
98
|
+
className="series-edge-bridge"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
onPointerEnter={() => setOpen(true)}
|
|
101
|
+
/>
|
|
102
|
+
<div
|
|
103
|
+
className="series-edge-fly"
|
|
104
|
+
id={panelId}
|
|
105
|
+
onPointerEnter={() => setOpen(true)}
|
|
106
|
+
>
|
|
107
|
+
<ol className="series-edge-list">
|
|
108
|
+
{items.map((item) => (
|
|
109
|
+
<li key={item.path} className={item.current ? "cur" : undefined}>
|
|
110
|
+
<Link href={item.href} aria-current={item.current ? "page" : undefined}>
|
|
111
|
+
{item.title}
|
|
112
|
+
</Link>
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ol>
|
|
116
|
+
</div>
|
|
117
|
+
</nav>
|
|
118
|
+
);
|
|
119
|
+
}
|
package/app/layout.tsx
CHANGED
|
@@ -49,9 +49,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
49
49
|
images: [
|
|
50
50
|
{
|
|
51
51
|
url: "/og.png",
|
|
52
|
-
width:
|
|
53
|
-
height:
|
|
54
|
-
alt: `${siteConfig.name} · ${siteConfig.pages.home.title.replace("\n", "")}`,
|
|
52
|
+
width: 1200,
|
|
53
|
+
height: 630,
|
|
54
|
+
alt: `${siteConfig.name} · ${siteConfig.tagline} · ${siteConfig.pages.home.title.replace("\n", "")}`,
|
|
55
55
|
},
|
|
56
56
|
],
|
|
57
57
|
},
|
package/app/lib/i18n.ts
CHANGED
|
@@ -141,6 +141,7 @@ const zh = {
|
|
|
141
141
|
homeColumnCountStatus: (count: number, status: string) => `${count} 篇,${status}`,
|
|
142
142
|
columnDefaultStatus: "持续更新",
|
|
143
143
|
// Blog archive
|
|
144
|
+
blogEyebrow: "BLOG / 文章",
|
|
144
145
|
blogHeading: "文章",
|
|
145
146
|
blogDeck: (n: number) => `阶段性的完整沉淀,关于工程、架构与技术思考。共 ${n} 篇,按年份归档。`,
|
|
146
147
|
unknownYear: "未知",
|
|
@@ -170,6 +171,7 @@ const zh = {
|
|
|
170
171
|
seriesEyebrow: "主题专栏",
|
|
171
172
|
seriesCountStatus: (count: number, status: string) => `${count} 篇 · ${status}`,
|
|
172
173
|
// Columns index
|
|
174
|
+
columnsEyebrow: "COLUMNS / 专栏",
|
|
173
175
|
columnsFallbackTitle: "专栏",
|
|
174
176
|
columnsMeta: (count: number, status: string) => `阅读清单,${count} 篇,${status}`,
|
|
175
177
|
columnsFallbackDesc: "按顺序组织的专题文章系列。",
|
|
@@ -195,7 +197,7 @@ const zh = {
|
|
|
195
197
|
audioFallback: "你的浏览器不支持音频播放。",
|
|
196
198
|
thoughtPermalinkShort: "永久链接",
|
|
197
199
|
// About & GitHub profile
|
|
198
|
-
aboutEyebrow: "关于",
|
|
200
|
+
aboutEyebrow: "ABOUT / 关于",
|
|
199
201
|
aboutFallbackHeading: "关于",
|
|
200
202
|
aboutFallbackParagraphs: [
|
|
201
203
|
"一名工程师。平时写代码,也把想清楚的问题记下来。",
|
|
@@ -424,6 +426,7 @@ const en: typeof zh = {
|
|
|
424
426
|
homeColumnCountStatus: (count: number, status: string) => `${count} ${count === 1 ? "post" : "posts"}, ${status}`,
|
|
425
427
|
columnDefaultStatus: "Ongoing",
|
|
426
428
|
// Blog archive
|
|
429
|
+
blogEyebrow: "BLOG",
|
|
427
430
|
blogHeading: "Posts",
|
|
428
431
|
blogDeck: (n: number) => `Essays on engineering, architecture, and systems thinking. ${n} ${n === 1 ? "post" : "posts"}, archived by year.`,
|
|
429
432
|
unknownYear: "Unknown",
|
|
@@ -453,6 +456,7 @@ const en: typeof zh = {
|
|
|
453
456
|
seriesEyebrow: "Column",
|
|
454
457
|
seriesCountStatus: (count: number, status: string) => `${count} ${count === 1 ? "post" : "posts"} · ${status}`,
|
|
455
458
|
// Columns index
|
|
459
|
+
columnsEyebrow: "COLUMNS",
|
|
456
460
|
columnsFallbackTitle: "Columns",
|
|
457
461
|
columnsMeta: (count: number, status: string) => `Reading list · ${count} ${count === 1 ? "post" : "posts"} · ${status}`,
|
|
458
462
|
columnsFallbackDesc: "A series of posts organized in reading order.",
|
|
@@ -478,7 +482,7 @@ const en: typeof zh = {
|
|
|
478
482
|
audioFallback: "Your browser does not support audio playback.",
|
|
479
483
|
thoughtPermalinkShort: "Permalink",
|
|
480
484
|
// About & GitHub profile
|
|
481
|
-
aboutEyebrow: "
|
|
485
|
+
aboutEyebrow: "ABOUT",
|
|
482
486
|
aboutFallbackHeading: "About",
|
|
483
487
|
aboutFallbackParagraphs: [
|
|
484
488
|
"An engineer who writes code and writes down problems once they are clear.",
|
|
@@ -11,7 +11,8 @@ import { toString } from "mdast-util-to-string";
|
|
|
11
11
|
* paragraph (inside the same list item or quote).
|
|
12
12
|
*
|
|
13
13
|
* Cards are `garden-linkcard` elements carrying `data-url`, `data-text` and,
|
|
14
|
-
* for the standalone form, `data-standalone`.
|
|
14
|
+
* for the standalone form, `data-standalone`. Every http(s) jump link that
|
|
15
|
+
* asks for a card uses this same path — same-site and external alike.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
const MARKER = "{card}";
|
|
@@ -32,7 +33,7 @@ const meaningful = (children) =>
|
|
|
32
33
|
children.filter((child) => !(child.type === "text" && !child.value.trim()) && child.type !== "break");
|
|
33
34
|
|
|
34
35
|
/** Take the markers out of one parent's inline children; return the marked links. */
|
|
35
|
-
function takeMarkers(parent, source
|
|
36
|
+
function takeMarkers(parent, source) {
|
|
36
37
|
const marked = [];
|
|
37
38
|
const children = parent.children ?? [];
|
|
38
39
|
for (let index = 0; index < children.length - 1; index += 1) {
|
|
@@ -41,14 +42,13 @@ function takeMarkers(parent, source, sameSite) {
|
|
|
41
42
|
if (link.type !== "link" || next.type !== "text" || !next.value.startsWith(MARKER)) continue;
|
|
42
43
|
if (!isHttp(link.url) || !writtenAsLink(link, source)) continue;
|
|
43
44
|
next.value = next.value.slice(MARKER.length);
|
|
44
|
-
|
|
45
|
-
if (!sameSite(link.url)) marked.push(link);
|
|
45
|
+
marked.push(link);
|
|
46
46
|
}
|
|
47
47
|
parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
|
|
48
48
|
return marked;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function walk(parent, source
|
|
51
|
+
function walk(parent, source) {
|
|
52
52
|
const children = parent.children ?? [];
|
|
53
53
|
for (let index = 0; index < children.length; index += 1) {
|
|
54
54
|
const node = children[index];
|
|
@@ -56,14 +56,14 @@ function walk(parent, source, sameSite) {
|
|
|
56
56
|
if (node.type !== "paragraph") {
|
|
57
57
|
if (node.children) {
|
|
58
58
|
// Headings, table cells and the like: drop the marker, no card.
|
|
59
|
-
if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source
|
|
60
|
-
walk(node, source
|
|
59
|
+
if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source);
|
|
60
|
+
walk(node, source);
|
|
61
61
|
}
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
64
|
// Links nested in emphasis count too.
|
|
65
|
-
const marked = takeMarkers(node, source
|
|
66
|
-
for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source
|
|
65
|
+
const marked = takeMarkers(node, source);
|
|
66
|
+
for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source));
|
|
67
67
|
if (!marked.length) continue;
|
|
68
68
|
const rest = meaningful(node.children);
|
|
69
69
|
if (marked.length === 1 && rest.length === 1 && rest[0] === marked[0]) {
|
|
@@ -97,11 +97,10 @@ function markSources(parent, source) {
|
|
|
97
97
|
parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
export function remarkLinkCard(
|
|
101
|
-
const sameSite = typeof options?.sameSite === "function" ? options.sameSite : () => null;
|
|
100
|
+
export function remarkLinkCard() {
|
|
102
101
|
return (tree, file) => {
|
|
103
102
|
const source = typeof file?.value === "string" ? file.value : String(file?.value ?? "");
|
|
104
103
|
markSources(tree, source);
|
|
105
|
-
walk(tree, source
|
|
104
|
+
walk(tree, source);
|
|
106
105
|
};
|
|
107
106
|
}
|
package/app/page.tsx
CHANGED
|
@@ -43,7 +43,9 @@ export default function Home() {
|
|
|
43
43
|
<main className="sh-main" id="content">
|
|
44
44
|
<section className="sh-intro">
|
|
45
45
|
<h1>{siteConfig.pages.home.title}</h1>
|
|
46
|
-
|
|
46
|
+
{siteConfig.pages.home.subtitle?.trim() ? (
|
|
47
|
+
<p>{siteConfig.pages.home.subtitle}</p>
|
|
48
|
+
) : null}
|
|
47
49
|
<div className="links">
|
|
48
50
|
<Link href={siteHref("/about")}>{t.homeAbout}</Link>
|
|
49
51
|
<a href={githubUrl} rel="me">GitHub</a>
|
package/app/styles/a2.css
CHANGED
|
@@ -2636,3 +2636,207 @@ html[data-theme="dark"] .ref-demo { --rd-s: #262624; --rd-p: #1f1e1d; --rd-i: #e
|
|
|
2636
2636
|
.ref-demo[data-name="figure"] .ref-pane[data-p="v"] { grid-column: 1; grid-row: 1; }
|
|
2637
2637
|
.ref-demo[data-name="figure"] .ref-pane[data-p="c"] { grid-column: 1; grid-row: 2; background: var(--rd-s); border-top: 1px solid var(--rd-r); }
|
|
2638
2638
|
}
|
|
2639
|
+
|
|
2640
|
+
/* ===== v0.10.1 tip keep (after git checkout of a2.css from 9a80f7e5) ===== */
|
|
2641
|
+
|
|
2642
|
+
/* List pages: PageIntro rides on .sh-head; keep eyebrow in the same vertical rhythm. */
|
|
2643
|
+
.page-intro.sh-head {
|
|
2644
|
+
padding: 72px 0 36px;
|
|
2645
|
+
max-width: var(--list);
|
|
2646
|
+
width: 100%;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
.page-intro.sh-head .eyebrow {
|
|
2650
|
+
margin: 0 0 14px;
|
|
2651
|
+
color: var(--muted);
|
|
2652
|
+
font: 600 11px / 1.4 var(--sans);
|
|
2653
|
+
letter-spacing: 0.12em;
|
|
2654
|
+
text-transform: uppercase;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
.page-intro.sh-head h1 {
|
|
2658
|
+
margin: 0;
|
|
2659
|
+
font: 700 var(--fs-display) / 1.2 var(--sans);
|
|
2660
|
+
color: var(--ink);
|
|
2661
|
+
letter-spacing: 0;
|
|
2662
|
+
text-wrap: balance;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
.page-intro.sh-head .sh-deck {
|
|
2666
|
+
margin: 14px 0 0;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
.page-intro.sh-head .page-intro-actions {
|
|
2670
|
+
margin-top: 22px;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
.thoughts-page,
|
|
2674
|
+
.about-page {
|
|
2675
|
+
width: auto;
|
|
2676
|
+
max-width: none;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
.about-page {
|
|
2680
|
+
padding-bottom: 120px;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/* Keep PageIntro at --list; focus card sticks to the right edge. */
|
|
2684
|
+
.about-page .about-grid {
|
|
2685
|
+
width: 100%;
|
|
2686
|
+
max-width: none;
|
|
2687
|
+
display: grid;
|
|
2688
|
+
grid-template-columns: minmax(0, var(--list)) minmax(12rem, 17rem);
|
|
2689
|
+
column-gap: 48px;
|
|
2690
|
+
row-gap: 36px;
|
|
2691
|
+
justify-content: space-between;
|
|
2692
|
+
align-items: start;
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
@media (max-width: 900px) {
|
|
2696
|
+
.about-page .about-grid {
|
|
2697
|
+
grid-template-columns: 1fr;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
/* SeriesEdgeNav: fixed to viewport left on column posts that share PostReadingView.
|
|
2702
|
+
post-reading-page already lays out article + TOC; ticks are position:fixed so
|
|
2703
|
+
they do not need a grid track. */
|
|
2704
|
+
.post-reading-page > .series-edge {
|
|
2705
|
+
--edge-window-inset: 12px;
|
|
2706
|
+
position: fixed;
|
|
2707
|
+
top: calc(var(--hdr) + 12px);
|
|
2708
|
+
bottom: 16px;
|
|
2709
|
+
left: max(var(--edge-window-inset), env(safe-area-inset-left, 0px));
|
|
2710
|
+
z-index: 15;
|
|
2711
|
+
display: flex;
|
|
2712
|
+
align-items: center;
|
|
2713
|
+
pointer-events: none;
|
|
2714
|
+
transform: none;
|
|
2715
|
+
margin: 0;
|
|
2716
|
+
padding: 0;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
.series-edge-ticks {
|
|
2720
|
+
pointer-events: auto;
|
|
2721
|
+
appearance: none;
|
|
2722
|
+
border: 0;
|
|
2723
|
+
background: transparent;
|
|
2724
|
+
box-shadow: none;
|
|
2725
|
+
display: flex;
|
|
2726
|
+
flex-direction: column;
|
|
2727
|
+
justify-content: center;
|
|
2728
|
+
align-items: center;
|
|
2729
|
+
gap: 8px;
|
|
2730
|
+
padding: 12px 6px;
|
|
2731
|
+
margin: 0;
|
|
2732
|
+
cursor: pointer;
|
|
2733
|
+
color: inherit;
|
|
2734
|
+
font: inherit;
|
|
2735
|
+
border-radius: 0;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
.series-edge-ticks i {
|
|
2739
|
+
display: block;
|
|
2740
|
+
width: 11px;
|
|
2741
|
+
height: 1.5px;
|
|
2742
|
+
border-radius: 1px;
|
|
2743
|
+
background: color-mix(in srgb, var(--ink) 32%, transparent);
|
|
2744
|
+
transition: background 0.15s ease, width 0.15s ease;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
.series-edge-ticks i.hot {
|
|
2748
|
+
width: 15px;
|
|
2749
|
+
background: color-mix(in srgb, var(--ink) 70%, transparent);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
.series-edge[data-open="true"] .series-edge-ticks i,
|
|
2753
|
+
.series-edge:focus-within .series-edge-ticks i {
|
|
2754
|
+
background: color-mix(in srgb, var(--ink) 52%, transparent);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
.series-edge[data-open="true"] .series-edge-ticks i.hot,
|
|
2758
|
+
.series-edge:focus-within .series-edge-ticks i.hot {
|
|
2759
|
+
background: var(--ink);
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
.series-edge-bridge {
|
|
2763
|
+
pointer-events: auto;
|
|
2764
|
+
position: absolute;
|
|
2765
|
+
top: 0;
|
|
2766
|
+
bottom: 0;
|
|
2767
|
+
left: 100%;
|
|
2768
|
+
width: 28px;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
.series-edge-fly {
|
|
2772
|
+
pointer-events: none;
|
|
2773
|
+
position: absolute;
|
|
2774
|
+
top: 50%;
|
|
2775
|
+
left: calc(100% + 4px);
|
|
2776
|
+
transform: translate(0, -50%);
|
|
2777
|
+
width: 16rem;
|
|
2778
|
+
max-height: min(72vh, calc(100vh - var(--hdr) - 40px));
|
|
2779
|
+
margin: 0;
|
|
2780
|
+
padding: 0.7rem 0.85rem 0.75rem;
|
|
2781
|
+
border: 0;
|
|
2782
|
+
border-radius: 10px;
|
|
2783
|
+
background: var(--paper);
|
|
2784
|
+
box-shadow:
|
|
2785
|
+
0 1px 2px color-mix(in srgb, var(--ink) 6%, transparent),
|
|
2786
|
+
0 10px 28px color-mix(in srgb, var(--ink) 10%, transparent);
|
|
2787
|
+
opacity: 0;
|
|
2788
|
+
visibility: hidden;
|
|
2789
|
+
overflow: auto;
|
|
2790
|
+
transition: opacity 0.16s ease, visibility 0.16s ease;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
.series-edge[data-open="true"],
|
|
2794
|
+
.series-edge:focus-within {
|
|
2795
|
+
z-index: 28;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
.series-edge[data-open="true"] .series-edge-fly,
|
|
2799
|
+
.series-edge:focus-within .series-edge-fly {
|
|
2800
|
+
pointer-events: auto;
|
|
2801
|
+
opacity: 1;
|
|
2802
|
+
visibility: visible;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
.series-edge-list {
|
|
2806
|
+
list-style: none;
|
|
2807
|
+
margin: 0;
|
|
2808
|
+
padding: 0;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
.series-edge-list a {
|
|
2812
|
+
display: block;
|
|
2813
|
+
padding: 0.4rem 0.35rem;
|
|
2814
|
+
margin: 0 -0.35rem;
|
|
2815
|
+
border-radius: 7px;
|
|
2816
|
+
color: var(--soft);
|
|
2817
|
+
font-size: 13px;
|
|
2818
|
+
line-height: 1.35;
|
|
2819
|
+
text-decoration: none;
|
|
2820
|
+
white-space: nowrap;
|
|
2821
|
+
overflow: hidden;
|
|
2822
|
+
text-overflow: ellipsis;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
.series-edge-list a:hover {
|
|
2826
|
+
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
|
2827
|
+
color: var(--ink);
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
.series-edge-list li.cur a {
|
|
2831
|
+
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
|
2832
|
+
color: var(--ink);
|
|
2833
|
+
font-weight: 600;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
@media (max-width: 900px) {
|
|
2837
|
+
/* The desktop rule is `.post-reading-page > .series-edge { display: flex }`.
|
|
2838
|
+
Match that specificity so the rail stays off small screens. */
|
|
2839
|
+
.post-reading-page > .series-edge {
|
|
2840
|
+
display: none;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
@@ -36,7 +36,7 @@ export default async function ThoughtsPage({ params }: { params: Promise<{ n: st
|
|
|
36
36
|
notFound();
|
|
37
37
|
}
|
|
38
38
|
return (
|
|
39
|
-
<main className="
|
|
39
|
+
<main className="sh-main thoughts-page" id="content">
|
|
40
40
|
<PageIntro eyebrow={ui().thoughtsEyebrow} title={siteConfig.pages.thoughts.title} subtitle={siteConfig.pages.thoughts.subtitle} />
|
|
41
41
|
<ThoughtFeed page={page} />
|
|
42
42
|
</main>
|
package/app/thoughts/page.tsx
CHANGED
|
@@ -16,7 +16,7 @@ export default function ThoughtsPage() {
|
|
|
16
16
|
const thoughts = getPublishedThoughts();
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
|
-
<main className="
|
|
19
|
+
<main className="sh-main thoughts-page" id="content">
|
|
20
20
|
<PageIntro
|
|
21
21
|
eyebrow={ui().thoughtsEyebrow}
|
|
22
22
|
title={siteConfig.pages.thoughts.title}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
title:
|
|
2
|
+
title: 链接卡片
|
|
3
3
|
navTitle: 链接卡片
|
|
4
|
-
description:
|
|
4
|
+
description: 把一条跳转链接渲染成带网站名、标题和简介的卡片。
|
|
5
5
|
section: components
|
|
6
6
|
order: 43
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
单独占一段的网址,或链接末尾紧跟 `{card}
|
|
9
|
+
单独占一段的网址,或链接末尾紧跟 `{card}`,会渲染成链接卡片(同站与外站同一套逻辑)。正文里带标题的普通链接不会变成卡片,例如 [Typora](https://typora.io/):外站只在文字前加站点 Favicon,右上角标 `↗`,图标与第一个字留在同一行;同站标题链接仍在站内跳转。
|
|
10
10
|
|
|
11
11
|
- **什么时候用**:希望读者点开之前先看到网站名、标题和简介。
|
|
12
12
|
- **什么时候不用**:句子里只要标出出处时用[来源胶囊](/garden/components/source);要自己写几条要点时用[知识卡片](/garden/components/card)。
|
|
@@ -41,9 +41,9 @@ https://spec.commonmark.org/
|
|
|
41
41
|
|
|
42
42
|
[KaTeX 支持的函数](https://katex.org/docs/supported.html){card}
|
|
43
43
|
|
|
44
|
-
`{card}` 写在代码里会原样显示,例如 `[文字](https://example.com){card}`。标记和 `)`
|
|
44
|
+
`{card}` 写在代码里会原样显示,例如 `[文字](https://example.com){card}`。标记和 `)` 之间有空格时也不会变成卡片。同站与外站的跳链共用同一套卡片路径。
|
|
45
45
|
|
|
46
|
-
构建时读取正文里的 `{card}`
|
|
46
|
+
构建时读取正文里的 `{card}` 链接,以及单独成段的网址,把对方页面的标题、简介和封面记入 `link-assets.json`(同站与外站同一套抓取;HTML meta refresh 会跟着跳到目标页再读 OG)。抓取失败就沿用上次的结果,构建不会因此中断。只刷新这些信息时运行 `npx garden links`。离线构建时把 `GARDEN_OFFLINE` 设为 `1` 会跳过拉取。
|
|
47
47
|
|
|
48
48
|
随想 Front Matter 里的 `type: link` 用你写的 `title`、`description` 和 `image`,字段见[写文章与随想](/garden/writing)。
|
|
49
49
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
title:
|
|
2
|
+
title: Link Card
|
|
3
3
|
navTitle: Link card
|
|
4
|
-
description: Render
|
|
4
|
+
description: Render a jump URL as a card with the site name, title, and summary.
|
|
5
5
|
section: components
|
|
6
6
|
order: 43
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
A URL on its own paragraph, or a link followed immediately by `{card}`, renders as a link card. An ordinary
|
|
9
|
+
A URL on its own paragraph, or a link followed immediately by `{card}`, renders as a link card (same-site and external share one path). An ordinary titled link in prose stays a link, such as [Typora](https://typora.io/): an external one shows the site favicon before the text and a `↗` at the upper right; a titled same-site link still navigates in place.
|
|
10
10
|
|
|
11
11
|
- **When to use**: The reader should see the site name, title, and summary before opening the page.
|
|
12
12
|
- **When to skip**: Use a [source chip](/garden/en/components/source) to cite a site inside a sentence, or a [reference card](/garden/en/components/card) when you are writing the takeaways yourself.
|
|
@@ -41,9 +41,9 @@ When the paragraph is only the marked link, that line becomes the card:
|
|
|
41
41
|
|
|
42
42
|
[Supported KaTeX functions](https://katex.org/docs/supported.html){card}
|
|
43
43
|
|
|
44
|
-
`{card}` inside code stays as written, for example `[text](https://example.com){card}`. A space between `)` and the marker also leaves an ordinary link.
|
|
44
|
+
`{card}` inside code stays as written, for example `[text](https://example.com){card}`. A space between `)` and the marker also leaves an ordinary link. Same-site and external jump links share one card path.
|
|
45
45
|
|
|
46
|
-
At build time, Garden reads `{card}` links
|
|
46
|
+
At build time, Garden reads `{card}` links and URLs that stand alone as a paragraph, and stores the page title, summary, and image in `link-assets.json` (same-site and external share one fetch path; HTML meta refresh is followed to the target before reading OG). A failed fetch keeps the previous record, and the build continues. Run `npx garden links` to refresh just these records. Set `GARDEN_OFFLINE` to `1` to skip the fetch during an offline build.
|
|
47
47
|
|
|
48
48
|
A thought can also carry an external link in Front Matter with `type: link`, using the `title`, `description`, and `image` you write. Those fields are in [Writing Posts & Thoughts](/garden/en/writing).
|
|
49
49
|
|
package/package.json
CHANGED
package/public/og.png
CHANGED
|
Binary file
|
|
@@ -12,7 +12,7 @@ import { referenceDomain } from "../ui/garden/references.mjs";
|
|
|
12
12
|
import { linkAssetsForRender } from "./link-assets.mjs";
|
|
13
13
|
import { cardUrls } from "../ui/garden/markdown-links.mjs";
|
|
14
14
|
import { formatInlineLink, replaceInlineLinks } from "../ui/garden/markdown-destination.mjs";
|
|
15
|
-
import { configuredSiteAddress
|
|
15
|
+
import { configuredSiteAddress } from "../ui/garden/same-site.mjs";
|
|
16
16
|
import { resolveBrand } from "./brand.mjs";
|
|
17
17
|
import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
|
|
18
18
|
import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
|
|
@@ -74,7 +74,6 @@ const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
|
|
|
74
74
|
: siteUrl;
|
|
75
75
|
// Full URLs to this address are internal links: no new tab, no card.
|
|
76
76
|
const siteAddress = configuredSiteAddress(siteConfig, process.env);
|
|
77
|
-
const sameSite = sameSiteMatcher(siteAddress, basePath);
|
|
78
77
|
const siteName = process.env.SITE_NAME ?? siteConfig.name;
|
|
79
78
|
const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
|
|
80
79
|
const siteLang = /^en/iu.test(String(siteConfig.locale ?? "")) ? "en" : "zh";
|
|
@@ -997,7 +996,7 @@ const sitemapEntries = [
|
|
|
997
996
|
// Card URLs in published entries: the only pages the link-card Worker may read.
|
|
998
997
|
fs.writeFileSync(
|
|
999
998
|
path.join(publicRoot, "link-allowlist.json"),
|
|
1000
|
-
`${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content))
|
|
999
|
+
`${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)))].sort() }, null, 2)}\n`,
|
|
1001
1000
|
);
|
|
1002
1001
|
fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
|
|
1003
1002
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
|
package/scripts/link-assets.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { isIP } from "node:net";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { pathToFileURL } from "node:url";
|
|
18
18
|
import matter from "gray-matter";
|
|
19
|
-
import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, linkHost } from "./link-meta-core.mjs";
|
|
19
|
+
import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, isSiteBrandOgImage, linkHost } from "./link-meta-core.mjs";
|
|
20
20
|
import { parse as parseYaml } from "yaml";
|
|
21
21
|
import { markdownLinks } from "../ui/garden/markdown-links.mjs";
|
|
22
22
|
import { gardenSourceUrls } from "../ui/garden/source-icon.mjs";
|
|
@@ -60,10 +60,11 @@ function readSiteConfig(siteRoot) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/** Jump links across every post: the sites (for favicons) and the URLs. */
|
|
64
64
|
export function collectSiteLinks(siteRoot, env = process.env) {
|
|
65
65
|
const config = readSiteConfig(siteRoot);
|
|
66
|
-
//
|
|
66
|
+
// Ordinary same-site prose links need no favicon fetch. Card URLs — same-site
|
|
67
|
+
// or external — share one OG/link-assets path.
|
|
67
68
|
const sameSite = sameSiteMatcher(configuredSiteAddress(config, env), env.NEXT_PUBLIC_BASE_PATH ?? "");
|
|
68
69
|
const hosts = new Map();
|
|
69
70
|
const cards = new Set();
|
|
@@ -71,7 +72,8 @@ export function collectSiteLinks(siteRoot, env = process.env) {
|
|
|
71
72
|
for (const file of markdownFiles(path.join(siteRoot, "content"))) {
|
|
72
73
|
const { content } = matter(fs.readFileSync(file, "utf8"));
|
|
73
74
|
for (const { url, card } of markdownLinks(content)) {
|
|
74
|
-
if (!isPublicUrl(url)
|
|
75
|
+
if (!isPublicUrl(url)) continue;
|
|
76
|
+
if (sameSite(url) && !card) continue;
|
|
75
77
|
urls.add(url);
|
|
76
78
|
if (card) cards.add(url);
|
|
77
79
|
const host = linkHost(url);
|
|
@@ -184,7 +186,7 @@ export async function refreshLinkAssets({
|
|
|
184
186
|
const meta = await fetchPageMeta(url, { fetch: fetchImpl, timeoutMs });
|
|
185
187
|
if (!meta.title) return keepCachedCard(url);
|
|
186
188
|
let image;
|
|
187
|
-
if (meta.image) {
|
|
189
|
+
if (meta.image && !isSiteBrandOgImage(meta.image)) {
|
|
188
190
|
try {
|
|
189
191
|
const picture = await shrinkImage(await fetchImage(meta.image, { fetch: fetchImpl, timeoutMs: timeoutMs * 2 }));
|
|
190
192
|
const name = `${digest(Buffer.from(url))}-${digest(picture.bytes)}.${picture.ext}`;
|