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
|
@@ -10,11 +10,79 @@ import {
|
|
|
10
10
|
useState,
|
|
11
11
|
type KeyboardEvent,
|
|
12
12
|
} from "react";
|
|
13
|
-
import
|
|
13
|
+
import type Fuse from "fuse.js";
|
|
14
14
|
import { MagnifyingGlass, X } from "@phosphor-icons/react";
|
|
15
|
-
import {
|
|
15
|
+
import { searchFull, searchIndexUrl } from "@garden/site-config";
|
|
16
|
+
import { createSearch } from "../../ui/garden/search-match.mjs";
|
|
17
|
+
import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
|
|
16
18
|
import { siteConfig } from "../site.config";
|
|
17
19
|
|
|
20
|
+
/** One entry of /search-index.json: titles, tags, dates and a short excerpt, never the full body. */
|
|
21
|
+
type SearchRecord = {
|
|
22
|
+
title: string;
|
|
23
|
+
description: string;
|
|
24
|
+
section: string;
|
|
25
|
+
path: string;
|
|
26
|
+
content: string;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
date?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type SearchEngine = { records: SearchRecord[]; fuse: Fuse<SearchRecord> };
|
|
32
|
+
let engineLoad: Promise<SearchEngine> | null = null;
|
|
33
|
+
|
|
34
|
+
type MatchRange = readonly [number, number];
|
|
35
|
+
type SearchMatch = { key?: string; indices: readonly MatchRange[] };
|
|
36
|
+
type SearchHit = { item: SearchRecord; matches?: readonly SearchMatch[] };
|
|
37
|
+
|
|
38
|
+
let fullLoad: Promise<Map<string, string>> | null = null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every body as plain text. Started in the background after the page has
|
|
42
|
+
* loaded, or when search opens first; read from IndexedDB when this version
|
|
43
|
+
* was fetched before.
|
|
44
|
+
*/
|
|
45
|
+
function loadFullText() {
|
|
46
|
+
const scope = searchFull.url.slice(0, searchFull.url.lastIndexOf("/"));
|
|
47
|
+
fullLoad ??= loadFullIndex({ url: searchFull.url, hash: searchFull.hash, store: createIdbStore(scope) })
|
|
48
|
+
.then((docs) => new Map(docs))
|
|
49
|
+
.catch((error: unknown) => {
|
|
50
|
+
fullLoad = null;
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
53
|
+
return fullLoad;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Index and Fuse are fetched together, the first time search is about to open. */
|
|
57
|
+
function loadSearchEngine() {
|
|
58
|
+
engineLoad ??= Promise.all([
|
|
59
|
+
fetch(searchIndexUrl).then((response) => {
|
|
60
|
+
if (!response.ok) throw new Error(`search index ${response.status}`);
|
|
61
|
+
return response.json() as Promise<SearchRecord[]>;
|
|
62
|
+
}),
|
|
63
|
+
import("fuse.js"),
|
|
64
|
+
])
|
|
65
|
+
.then(([records, { default: FuseClass }]) => ({
|
|
66
|
+
records,
|
|
67
|
+
fuse: new FuseClass(records, {
|
|
68
|
+
keys: [
|
|
69
|
+
{ name: "title", weight: 0.5 },
|
|
70
|
+
{ name: "description", weight: 0.3 },
|
|
71
|
+
{ name: "tags", weight: 0.2 },
|
|
72
|
+
{ name: "content", weight: 0.15 },
|
|
73
|
+
],
|
|
74
|
+
threshold: 0.36,
|
|
75
|
+
ignoreLocation: true,
|
|
76
|
+
includeMatches: true,
|
|
77
|
+
}),
|
|
78
|
+
}))
|
|
79
|
+
.catch((error) => {
|
|
80
|
+
engineLoad = null;
|
|
81
|
+
throw error;
|
|
82
|
+
});
|
|
83
|
+
return engineLoad;
|
|
84
|
+
}
|
|
85
|
+
|
|
18
86
|
const nav = [
|
|
19
87
|
{ href: "/blog", label: "文章" },
|
|
20
88
|
{ href: "/thoughts", label: "随想" },
|
|
@@ -23,10 +91,8 @@ const nav = [
|
|
|
23
91
|
{ href: "/about", label: "关于" },
|
|
24
92
|
];
|
|
25
93
|
|
|
26
|
-
type MatchRange = readonly [number, number];
|
|
27
|
-
|
|
28
94
|
function rangesFor(
|
|
29
|
-
matches: readonly
|
|
95
|
+
matches: readonly SearchMatch[] | undefined,
|
|
30
96
|
key: string,
|
|
31
97
|
): readonly MatchRange[] {
|
|
32
98
|
return matches?.find((match) => match.key === key)?.indices ?? [];
|
|
@@ -119,8 +185,8 @@ function cropMatchedText(text: string, ranges: readonly MatchRange[]) {
|
|
|
119
185
|
}
|
|
120
186
|
|
|
121
187
|
function searchPreview(
|
|
122
|
-
item:
|
|
123
|
-
matches: readonly
|
|
188
|
+
item: SearchRecord,
|
|
189
|
+
matches: readonly SearchMatch[] | undefined,
|
|
124
190
|
): { text: string; ranges: readonly MatchRange[] } {
|
|
125
191
|
const descriptionRanges = rangesFor(matches, "description");
|
|
126
192
|
if (descriptionRanges.length) {
|
|
@@ -147,7 +213,25 @@ export function SiteHeader() {
|
|
|
147
213
|
const resultRefs = useRef<Array<HTMLAnchorElement | null>>([]);
|
|
148
214
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
149
215
|
|
|
216
|
+
const [engine, setEngine] = useState<SearchEngine | null>(null);
|
|
217
|
+
const [engineFailed, setEngineFailed] = useState(false);
|
|
218
|
+
const [fullText, setFullText] = useState<Map<string, string> | null>(null);
|
|
219
|
+
const prefetchSearch = useCallback(() => {
|
|
220
|
+
loadSearchEngine().then(setEngine, () => setEngineFailed(true));
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
// Full text arrives quietly once the page is done; search upgrades when it lands.
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (saveDataOn()) return;
|
|
226
|
+
return whenIdleAfterLoad(() => {
|
|
227
|
+
loadFullText().then(setFullText, () => {});
|
|
228
|
+
});
|
|
229
|
+
}, []);
|
|
230
|
+
|
|
150
231
|
const openSearch = useCallback(() => {
|
|
232
|
+
setEngineFailed(false);
|
|
233
|
+
loadSearchEngine().then(setEngine, () => setEngineFailed(true));
|
|
234
|
+
loadFullText().then(setFullText, () => {});
|
|
151
235
|
previousFocusRef.current =
|
|
152
236
|
document.activeElement instanceof HTMLElement
|
|
153
237
|
? document.activeElement
|
|
@@ -190,24 +274,14 @@ export function SiteHeader() {
|
|
|
190
274
|
return () => window.removeEventListener("keydown", onKey);
|
|
191
275
|
}, [closeSearch, openSearch, searchOpen]);
|
|
192
276
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
threshold: 0.36,
|
|
202
|
-
ignoreLocation: true,
|
|
203
|
-
includeMatches: true,
|
|
204
|
-
}),
|
|
205
|
-
[],
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
const results = query.trim()
|
|
209
|
-
? fuse.search(query).slice(0, 6)
|
|
210
|
-
: searchRecords.slice(0, 5).map((item) => ({ item, matches: [] }));
|
|
277
|
+
// Excerpts first, full text once it is here. Typos fall back to Fuse's fuzzy match.
|
|
278
|
+
const matcher = useMemo(() => (engine ? createSearch(engine.records, fullText) : null), [engine, fullText]);
|
|
279
|
+
const results: SearchHit[] = useMemo(() => {
|
|
280
|
+
if (!engine || !matcher) return [];
|
|
281
|
+
if (!query.trim()) return engine.records.slice(0, 5).map((item) => ({ item, matches: [] }));
|
|
282
|
+
const exact = matcher.search(query, 6) as unknown as SearchHit[];
|
|
283
|
+
return exact.length ? exact : engine.fuse.search(query).slice(0, 6);
|
|
284
|
+
}, [engine, matcher, query]);
|
|
211
285
|
|
|
212
286
|
useEffect(() => {
|
|
213
287
|
if (!searchOpen) return;
|
|
@@ -293,7 +367,7 @@ export function SiteHeader() {
|
|
|
293
367
|
))}
|
|
294
368
|
</nav>
|
|
295
369
|
<div className="sh-tools">
|
|
296
|
-
<button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} aria-label="搜索文章" aria-expanded={searchOpen} aria-controls="site-search-dialog">
|
|
370
|
+
<button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label="搜索文章" aria-expanded={searchOpen} aria-controls="site-search-dialog">
|
|
297
371
|
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
298
372
|
<circle cx="9" cy="9" r="5.8" />
|
|
299
373
|
<path d="M13.3 13.3 17 17" />
|
|
@@ -301,7 +375,7 @@ export function SiteHeader() {
|
|
|
301
375
|
<span className="lbl">搜索文章</span>
|
|
302
376
|
<kbd><span className="kbd-mod kbd-mac">⌘</span><span className="kbd-mod kbd-other">Ctrl</span>{" "}K</kbd>
|
|
303
377
|
</button>
|
|
304
|
-
<button className="sh-icon sh-search-m" type="button" onClick={openSearch} aria-label="搜索文章">
|
|
378
|
+
<button className="sh-icon sh-search-m" type="button" onClick={openSearch} onPointerDown={prefetchSearch} aria-label="搜索文章">
|
|
305
379
|
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
306
380
|
<circle cx="9" cy="9" r="5.8" />
|
|
307
381
|
<path d="M13.3 13.3 17 17" />
|
|
@@ -415,7 +489,9 @@ export function SiteHeader() {
|
|
|
415
489
|
);
|
|
416
490
|
})}
|
|
417
491
|
{results.length === 0 && (
|
|
418
|
-
<p className="empty-state"
|
|
492
|
+
<p className="empty-state">
|
|
493
|
+
{engine ? "没有找到,换个更宽松的关键词试试。" : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
|
|
494
|
+
</p>
|
|
419
495
|
)}
|
|
420
496
|
</div>
|
|
421
497
|
<footer className="search-footer">
|
package/app/garden-modules.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ declare module "@garden/generated-content" {
|
|
|
19
19
|
publishAt?: string;
|
|
20
20
|
draft?: boolean;
|
|
21
21
|
tags?: string[];
|
|
22
|
-
references?: { title: string; url?: string }[];
|
|
22
|
+
references?: { title: string; url?: string; domain?: string }[];
|
|
23
23
|
pinned?: boolean;
|
|
24
24
|
readingTime?: string;
|
|
25
25
|
kind: "post" | "column" | "thought";
|
|
@@ -33,6 +33,8 @@ declare module "@garden/generated-content" {
|
|
|
33
33
|
columnCoverAlt?: string;
|
|
34
34
|
order?: number;
|
|
35
35
|
cover?: string;
|
|
36
|
+
/** Resized WebP copies of a local cover, when the build had sharp. */
|
|
37
|
+
coverImage?: { src: string; srcset: string; width: number; height: number };
|
|
36
38
|
original?: string;
|
|
37
39
|
mediaType?: "image" | "audio" | "video" | "link" | "text";
|
|
38
40
|
media?: string | MediaItem[];
|
|
@@ -47,9 +49,22 @@ declare module "@garden/generated-content" {
|
|
|
47
49
|
export const columns: ContentEntry[];
|
|
48
50
|
export const thoughts: ContentEntry[];
|
|
49
51
|
export const a2WordCounts: Record<string, number>;
|
|
52
|
+
export type LinkCardMeta = {
|
|
53
|
+
title: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
siteName?: string;
|
|
56
|
+
author?: string;
|
|
57
|
+
publishedTime?: string;
|
|
58
|
+
image?: string;
|
|
59
|
+
};
|
|
60
|
+
export type LinkAssets = { favicons: Record<string, string>; cards: Record<string, LinkCardMeta> };
|
|
61
|
+
export const linkAssets: LinkAssets;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
declare module "@garden/site-config" {
|
|
65
|
+
export const searchIndexUrl: string;
|
|
66
|
+
/** The full-text index file and the hash its contents are cached under. */
|
|
67
|
+
export const searchFull: { url: string; hash: string };
|
|
53
68
|
export const siteConfig: {
|
|
54
69
|
name: string;
|
|
55
70
|
tagline: string;
|
|
@@ -58,6 +73,7 @@ declare module "@garden/site-config" {
|
|
|
58
73
|
author: { name: string; github: string; githubBio: string; githubPinned?: string[] };
|
|
59
74
|
pages: Record<string, Record<string, unknown>>;
|
|
60
75
|
footer: string;
|
|
76
|
+
linkPreview?: { worker?: string };
|
|
61
77
|
};
|
|
62
78
|
export const githubUrl: string;
|
|
63
79
|
}
|
package/app/globals.css
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
--accent: #30302e;
|
|
16
16
|
--accent-soft: #f0eee6;
|
|
17
17
|
--selection: #e8e5db;
|
|
18
|
-
--font-mono:
|
|
18
|
+
--font-mono: "SFMono-Regular", Consolas,
|
|
19
19
|
"Liberation Mono", "Noto Sans Mono CJK SC", monospace;
|
|
20
20
|
--content: 740px;
|
|
21
21
|
--wide: 1040px;
|
|
@@ -2763,65 +2763,103 @@ button {
|
|
|
2763
2763
|
color: #7f8790;
|
|
2764
2764
|
}
|
|
2765
2765
|
|
|
2766
|
+
/* References: one quiet warm-grey card after the article. */
|
|
2766
2767
|
.article-references {
|
|
2767
|
-
margin-top:
|
|
2768
|
+
margin-top: 56px;
|
|
2769
|
+
padding: 22px 24px 16px;
|
|
2770
|
+
background: var(--card-hover);
|
|
2771
|
+
border: 1px solid var(--rule);
|
|
2772
|
+
border-radius: var(--r);
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
:root[data-theme="dark"] .article-references,
|
|
2776
|
+
html[data-theme="dark"] .article-references {
|
|
2777
|
+
background: var(--card);
|
|
2768
2778
|
}
|
|
2769
2779
|
|
|
2770
|
-
.article-references >
|
|
2780
|
+
.article-references > h2 {
|
|
2771
2781
|
margin: 0 0 14px;
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
font
|
|
2782
|
+
padding: 0;
|
|
2783
|
+
border: 0;
|
|
2784
|
+
font: 600 17px / 1.4 var(--sans);
|
|
2785
|
+
color: var(--ink);
|
|
2775
2786
|
}
|
|
2776
2787
|
|
|
2777
|
-
.article-references
|
|
2778
|
-
|
|
2779
|
-
border-collapse: collapse;
|
|
2780
|
-
font-size: 12px;
|
|
2788
|
+
.article-references > h2::before {
|
|
2789
|
+
content: none;
|
|
2781
2790
|
}
|
|
2782
2791
|
|
|
2783
|
-
.article-references
|
|
2792
|
+
.article-references ol {
|
|
2793
|
+
list-style: none;
|
|
2794
|
+
margin: 0;
|
|
2784
2795
|
padding: 0;
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2796
|
+
counter-reset: ref;
|
|
2797
|
+
font-size: 14px;
|
|
2798
|
+
line-height: 1.6;
|
|
2799
|
+
color: var(--soft);
|
|
2789
2800
|
}
|
|
2790
2801
|
|
|
2791
|
-
.article-references
|
|
2792
|
-
|
|
2793
|
-
|
|
2802
|
+
.article-references li {
|
|
2803
|
+
position: relative;
|
|
2804
|
+
margin: 0 0 6px;
|
|
2805
|
+
padding-left: 2.1em;
|
|
2806
|
+
counter-increment: ref;
|
|
2807
|
+
break-inside: avoid;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
.article-references li::before {
|
|
2811
|
+
content: counter(ref);
|
|
2812
|
+
position: absolute;
|
|
2813
|
+
top: 0.15em;
|
|
2814
|
+
left: 0;
|
|
2815
|
+
width: 1.5em;
|
|
2816
|
+
color: var(--muted);
|
|
2817
|
+
font-size: 12px;
|
|
2794
2818
|
font-variant-numeric: tabular-nums;
|
|
2795
2819
|
text-align: right;
|
|
2796
|
-
white-space: nowrap;
|
|
2797
2820
|
}
|
|
2798
2821
|
|
|
2799
|
-
.article-references
|
|
2800
|
-
.article-references
|
|
2801
|
-
|
|
2802
|
-
padding: 11px 13px;
|
|
2822
|
+
.article-references a,
|
|
2823
|
+
.article-references .ref-title {
|
|
2824
|
+
color: var(--text);
|
|
2803
2825
|
}
|
|
2804
2826
|
|
|
2805
|
-
.article-references
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
text-decoration:
|
|
2827
|
+
.article-references a {
|
|
2828
|
+
text-decoration: underline;
|
|
2829
|
+
text-decoration-thickness: 1px;
|
|
2830
|
+
text-underline-offset: 4px;
|
|
2831
|
+
text-decoration-color: color-mix(in srgb, var(--ink) 35%, transparent);
|
|
2832
|
+
transition: text-decoration-color 0.15s ease;
|
|
2810
2833
|
}
|
|
2811
2834
|
|
|
2812
|
-
.article-references
|
|
2813
|
-
color:
|
|
2814
|
-
text-decoration:
|
|
2835
|
+
.article-references a:hover {
|
|
2836
|
+
text-decoration-color: var(--ink);
|
|
2837
|
+
text-decoration-thickness: 2px;
|
|
2815
2838
|
}
|
|
2816
2839
|
|
|
2817
|
-
.article-references
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2840
|
+
.article-references .ref-domain {
|
|
2841
|
+
font-size: 12px;
|
|
2842
|
+
color: var(--muted);
|
|
2843
|
+
white-space: nowrap;
|
|
2821
2844
|
}
|
|
2822
2845
|
|
|
2823
|
-
|
|
2824
|
-
|
|
2846
|
+
@media (min-width: 900px) {
|
|
2847
|
+
.article-references.is-split ol {
|
|
2848
|
+
columns: 2;
|
|
2849
|
+
column-gap: 32px;
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
@media (max-width: 640px) {
|
|
2854
|
+
.article-references {
|
|
2855
|
+
padding: 18px 16px 12px;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2860
|
+
.article-references a {
|
|
2861
|
+
transition: none;
|
|
2862
|
+
}
|
|
2825
2863
|
}
|
|
2826
2864
|
|
|
2827
2865
|
.article-references + .next-entry {
|
package/app/layout.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
-
import { Geist_Mono } from "next/font/google";
|
|
3
2
|
import "katex/dist/katex.min.css";
|
|
4
3
|
import "./globals.css";
|
|
5
4
|
import "./styles/tokens.css";
|
|
@@ -13,11 +12,6 @@ import {
|
|
|
13
12
|
} from "./lib/site";
|
|
14
13
|
import { githubUrl, siteConfig } from "./site.config";
|
|
15
14
|
|
|
16
|
-
const geistMono = Geist_Mono({
|
|
17
|
-
variable: "--font-geist-mono",
|
|
18
|
-
subsets: ["latin"],
|
|
19
|
-
});
|
|
20
|
-
|
|
21
15
|
export async function generateMetadata(): Promise<Metadata> {
|
|
22
16
|
const siteUrl = await getSiteUrl();
|
|
23
17
|
|
|
@@ -100,7 +94,7 @@ export default function RootLayout({
|
|
|
100
94
|
/>
|
|
101
95
|
)}
|
|
102
96
|
</head>
|
|
103
|
-
<body
|
|
97
|
+
<body>
|
|
104
98
|
<a className="skip-link" href="#main-content">
|
|
105
99
|
跳到正文
|
|
106
100
|
</a>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { columnOfPost, columns, posts } from "./content";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the /blog list needs per post. The page passes this to the client
|
|
5
|
+
* archive as props, so post bodies never reach the browser bundle.
|
|
6
|
+
*/
|
|
7
|
+
export type ArchivePost = {
|
|
8
|
+
path: string;
|
|
9
|
+
title: string;
|
|
10
|
+
date: string;
|
|
11
|
+
tags?: string[];
|
|
12
|
+
description?: string;
|
|
13
|
+
pinned?: boolean;
|
|
14
|
+
column?: { slug: string; title: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ArchiveSeries = { slug: string; title: string; count: number };
|
|
18
|
+
|
|
19
|
+
export function archivePosts(): ArchivePost[] {
|
|
20
|
+
return posts.map((post) => {
|
|
21
|
+
const column = columnOfPost(post);
|
|
22
|
+
return {
|
|
23
|
+
path: post.path,
|
|
24
|
+
title: post.title,
|
|
25
|
+
date: post.date,
|
|
26
|
+
...(post.tags?.length ? { tags: post.tags } : {}),
|
|
27
|
+
...(post.description ? { description: post.description } : {}),
|
|
28
|
+
...(post.pinned ? { pinned: true } : {}),
|
|
29
|
+
...(column ? { column } : {}),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function archiveSeries(): ArchiveSeries[] {
|
|
35
|
+
const series = new Map<string, ArchiveSeries>();
|
|
36
|
+
for (const entry of columns) {
|
|
37
|
+
const slug = entry.column ?? "";
|
|
38
|
+
const current = series.get(slug);
|
|
39
|
+
if (current) current.count += 1;
|
|
40
|
+
else series.set(slug, { slug, title: entry.columnTitle ?? slug, count: 1 });
|
|
41
|
+
}
|
|
42
|
+
return [...series.values()];
|
|
43
|
+
}
|
package/app/lib/content.ts
CHANGED
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
import GithubSlugger from "github-slugger";
|
|
10
10
|
import { canonicalSiteDate } from "../../scripts/site-date.mjs";
|
|
11
11
|
import { demoDateLabel } from "./a2-meta";
|
|
12
|
-
import {
|
|
12
|
+
import { searchableText } from "../../ui/garden/search-index.mjs";
|
|
13
13
|
import { toString } from "mdast-util-to-string";
|
|
14
14
|
import remarkParse from "remark-parse";
|
|
15
15
|
import { unified } from "unified";
|
|
16
16
|
import { visit } from "unist-util-visit";
|
|
17
|
+
import { splitHeadingNumber } from "./heading-number";
|
|
17
18
|
|
|
18
19
|
export { columns, posts, thoughts };
|
|
19
20
|
export type { ContentEntry, MediaItem };
|
|
@@ -65,11 +66,8 @@ export const displayHeadingLevel = (depth: number, base: number) => {
|
|
|
65
66
|
return level > 3 ? 4 : level;
|
|
66
67
|
};
|
|
67
68
|
|
|
68
|
-
/**
|
|
69
|
-
export
|
|
70
|
-
const match = /^(\d+(?:\.\d+)*)\.?[ \t]+([\s\S]+)$/u.exec(text.trim());
|
|
71
|
-
return match ? { no: match[1], text: match[2] } : null;
|
|
72
|
-
};
|
|
69
|
+
/** Kept exactly as the author wrote it; see heading-number.ts. */
|
|
70
|
+
export { splitHeadingNumber };
|
|
73
71
|
|
|
74
72
|
/**
|
|
75
73
|
* Parses the same Markdown heading text that ReactMarkdown sends to
|
|
@@ -112,15 +110,7 @@ export const extractHeadings = (markdown: string) => {
|
|
|
112
110
|
return headings;
|
|
113
111
|
};
|
|
114
112
|
|
|
115
|
-
const toSearchableText = (markdown: string) =>
|
|
116
|
-
contentToSearchText(markdown)
|
|
117
|
-
.replace(/```[^\n]*\n([\s\S]*?)```/g, " $1 ")
|
|
118
|
-
.replace(/!\[([^\]]*)\]\([^)]+\)/g, " $1 ")
|
|
119
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, " $1 ")
|
|
120
|
-
.replace(/<[^>]+>/g, " ")
|
|
121
|
-
.replace(/[#>*_~`|[\]{}()]/g, " ")
|
|
122
|
-
.replace(/\s+/g, " ")
|
|
123
|
-
.trim();
|
|
113
|
+
const toSearchableText = (markdown: string) => searchableText(markdown);
|
|
124
114
|
|
|
125
115
|
export const estimateWordCount = (markdown: string) => {
|
|
126
116
|
const text = toSearchableText(markdown);
|
|
@@ -131,30 +121,6 @@ export const estimateWordCount = (markdown: string) => {
|
|
|
131
121
|
return hanCharacters + otherWords;
|
|
132
122
|
};
|
|
133
123
|
|
|
134
|
-
export const searchRecords = [
|
|
135
|
-
...posts.map((item) => ({
|
|
136
|
-
title: item.title,
|
|
137
|
-
description: item.description ?? "",
|
|
138
|
-
section: "文章",
|
|
139
|
-
path: postHref(item),
|
|
140
|
-
content: toSearchableText(item.content),
|
|
141
|
-
})),
|
|
142
|
-
...columns.map((item) => ({
|
|
143
|
-
title: item.title,
|
|
144
|
-
description: item.description ?? "",
|
|
145
|
-
section: item.columnTitle ?? "专栏",
|
|
146
|
-
path: columnHref(item),
|
|
147
|
-
content: toSearchableText(item.content),
|
|
148
|
-
})),
|
|
149
|
-
...thoughts.map((item) => ({
|
|
150
|
-
title: item.title,
|
|
151
|
-
description: toSearchableText(item.content),
|
|
152
|
-
section: "随想",
|
|
153
|
-
path: `/thoughts/${item.slug}`,
|
|
154
|
-
content: toSearchableText(item.content),
|
|
155
|
-
})),
|
|
156
|
-
];
|
|
157
|
-
|
|
158
124
|
export const findPost = (path: string): ContentEntry | undefined =>
|
|
159
125
|
posts.find((post) => post.path === path);
|
|
160
126
|
|
|
@@ -19,7 +19,7 @@ export type ContentEntry = {
|
|
|
19
19
|
publishAt?: string;
|
|
20
20
|
draft?: boolean;
|
|
21
21
|
tags?: string[];
|
|
22
|
-
references?: { title: string; url?: string }[];
|
|
22
|
+
references?: { title: string; url?: string; domain?: string }[];
|
|
23
23
|
pinned?: boolean;
|
|
24
24
|
readingTime?: string;
|
|
25
25
|
kind: "post" | "column" | "thought";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull an author-written section number (`2.`, `4.1`, `4.1.`) into the section
|
|
3
|
+
* marker and leave the heading sentence. The number is kept exactly as
|
|
4
|
+
* written, trailing dot included, so `## 2. KV Cache` shows `2.`.
|
|
5
|
+
*/
|
|
6
|
+
export const splitHeadingNumber = (text: string) => {
|
|
7
|
+
const match = /^(\d+(?:\.\d+)*\.?)[ \t]+([\s\S]+)$/u.exec(text.trim());
|
|
8
|
+
return match ? { no: match[1], text: match[2] } : null;
|
|
9
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { toString } from "mdast-util-to-string";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `[text](url){card}` asks for a link card.
|
|
5
|
+
*
|
|
6
|
+
* - The marker only counts written straight after the link's `)`. Inside code
|
|
7
|
+
* it is plain text and stays.
|
|
8
|
+
* - A paragraph that is nothing but the marked link becomes the card itself,
|
|
9
|
+
* in the same spot; the inline link is not kept.
|
|
10
|
+
* - Otherwise the link stays in the sentence and its card follows the
|
|
11
|
+
* paragraph (inside the same list item or quote).
|
|
12
|
+
*
|
|
13
|
+
* Cards are `garden-linkcard` elements carrying `data-url`, `data-text` and,
|
|
14
|
+
* for the standalone form, `data-standalone`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MARKER = "{card}";
|
|
18
|
+
const isHttp = (url) => /^https?:\/\//iu.test(url ?? "");
|
|
19
|
+
|
|
20
|
+
function writtenAsLink(node, source) {
|
|
21
|
+
if (!source || node.position?.end?.offset === undefined) return true;
|
|
22
|
+
return source[node.position.end.offset - 1] === ")";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cardNode(link, standalone) {
|
|
26
|
+
const properties = { dataUrl: link.url, dataText: toString(link).trim() };
|
|
27
|
+
if (standalone) properties.dataStandalone = "true";
|
|
28
|
+
return { type: "linkCard", data: { hName: "garden-linkcard", hProperties: properties }, children: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const meaningful = (children) =>
|
|
32
|
+
children.filter((child) => !(child.type === "text" && !child.value.trim()) && child.type !== "break");
|
|
33
|
+
|
|
34
|
+
/** Take the markers out of one parent's inline children; return the marked links. */
|
|
35
|
+
function takeMarkers(parent, source) {
|
|
36
|
+
const marked = [];
|
|
37
|
+
const children = parent.children ?? [];
|
|
38
|
+
for (let index = 0; index < children.length - 1; index += 1) {
|
|
39
|
+
const link = children[index];
|
|
40
|
+
const next = children[index + 1];
|
|
41
|
+
if (link.type !== "link" || next.type !== "text" || !next.value.startsWith(MARKER)) continue;
|
|
42
|
+
if (!isHttp(link.url) || !writtenAsLink(link, source)) continue;
|
|
43
|
+
next.value = next.value.slice(MARKER.length);
|
|
44
|
+
marked.push(link);
|
|
45
|
+
}
|
|
46
|
+
parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
|
|
47
|
+
return marked;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function walk(parent, source) {
|
|
51
|
+
const children = parent.children ?? [];
|
|
52
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
53
|
+
const node = children[index];
|
|
54
|
+
if (node.type === "code" || node.type === "inlineCode" || node.type === "html") continue;
|
|
55
|
+
if (node.type !== "paragraph") {
|
|
56
|
+
if (node.children) {
|
|
57
|
+
// Headings, table cells and the like: drop the marker, no card.
|
|
58
|
+
if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source);
|
|
59
|
+
walk(node, source);
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Links nested in emphasis count too.
|
|
64
|
+
const marked = takeMarkers(node, source);
|
|
65
|
+
for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source));
|
|
66
|
+
if (!marked.length) continue;
|
|
67
|
+
const rest = meaningful(node.children);
|
|
68
|
+
if (marked.length === 1 && rest.length === 1 && rest[0] === marked[0]) {
|
|
69
|
+
children[index] = cardNode(marked[0], true);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const cards = marked.map((link) => cardNode(link, false));
|
|
73
|
+
children.splice(index + 1, 0, ...cards);
|
|
74
|
+
index += cards.length;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function remarkLinkCard() {
|
|
79
|
+
return (tree, file) => {
|
|
80
|
+
const source = typeof file?.value === "string" ? file.value : String(file?.value ?? "");
|
|
81
|
+
walk(tree, source);
|
|
82
|
+
};
|
|
83
|
+
}
|