hd-garden 0.5.0 → 0.5.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/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 +94 -22
- package/app/components/SiteHeader.tsx +104 -28
- package/app/garden-modules.d.ts +20 -1
- package/app/globals.css +96 -41
- 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-a2.mjs +4 -4
- package/app/lib/remark-link-card.mjs +85 -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 +56 -12
- package/app/styles/tokens.css +1 -1
- package/bin/garden.mjs +6 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +53 -6
- package/scripts/cover-images.mjs +69 -0
- package/scripts/link-assets.mjs +284 -0
- package/scripts/link-meta-core.mjs +321 -0
- package/templates/site/site.config.yaml +2 -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/same-site.mjs +63 -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,24 @@ 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 };
|
|
68
|
+
/** The site address (site.config.yaml `url`, else SITE_URL) and base path, for spotting links back to the site. */
|
|
69
|
+
export const siteLinks: { address: string; basePath: string };
|
|
53
70
|
export const siteConfig: {
|
|
54
71
|
name: string;
|
|
55
72
|
tagline: string;
|
|
@@ -58,6 +75,8 @@ declare module "@garden/site-config" {
|
|
|
58
75
|
author: { name: string; github: string; githubBio: string; githubPinned?: string[] };
|
|
59
76
|
pages: Record<string, Record<string, unknown>>;
|
|
60
77
|
footer: string;
|
|
78
|
+
linkPreview?: { worker?: string };
|
|
79
|
+
url?: string;
|
|
61
80
|
};
|
|
62
81
|
export const githubUrl: string;
|
|
63
82
|
}
|
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;
|
|
@@ -2061,15 +2061,32 @@ button {
|
|
|
2061
2061
|
text-decoration-thickness: 2px;
|
|
2062
2062
|
}
|
|
2063
2063
|
|
|
2064
|
-
/*
|
|
2065
|
-
.markdown-body u
|
|
2064
|
+
/* Highlighter for key claims, as in the A2 demo: a soft stroke under the lower part of the line. */
|
|
2065
|
+
.markdown-body u,
|
|
2066
|
+
.markdown-body mark,
|
|
2067
|
+
.markdown-body .hl {
|
|
2068
|
+
color: var(--ink);
|
|
2069
|
+
font-weight: 500;
|
|
2066
2070
|
text-decoration: none;
|
|
2067
|
-
border-bottom:
|
|
2068
|
-
|
|
2071
|
+
border-bottom: 0;
|
|
2072
|
+
background: linear-gradient(to bottom, transparent 58%, var(--hl) 58%, var(--hl) 92%, transparent 92%);
|
|
2073
|
+
padding: 0 0.08em;
|
|
2069
2074
|
-webkit-box-decoration-break: clone;
|
|
2070
2075
|
box-decoration-break: clone;
|
|
2071
2076
|
}
|
|
2072
2077
|
|
|
2078
|
+
.markdown-body u strong,
|
|
2079
|
+
.markdown-body u u,
|
|
2080
|
+
.markdown-body mark strong,
|
|
2081
|
+
.markdown-body mark u,
|
|
2082
|
+
.markdown-body .hl strong,
|
|
2083
|
+
.markdown-body .hl u {
|
|
2084
|
+
font-weight: 600;
|
|
2085
|
+
text-decoration: none;
|
|
2086
|
+
background: none;
|
|
2087
|
+
padding: 0;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2073
2090
|
.markdown-body strong {
|
|
2074
2091
|
color: var(--ink);
|
|
2075
2092
|
}
|
|
@@ -2763,65 +2780,103 @@ button {
|
|
|
2763
2780
|
color: #7f8790;
|
|
2764
2781
|
}
|
|
2765
2782
|
|
|
2783
|
+
/* References: one quiet warm-grey card after the article. */
|
|
2766
2784
|
.article-references {
|
|
2767
|
-
margin-top:
|
|
2785
|
+
margin-top: 56px;
|
|
2786
|
+
padding: 22px 24px 16px;
|
|
2787
|
+
background: var(--card-hover);
|
|
2788
|
+
border: 1px solid var(--rule);
|
|
2789
|
+
border-radius: var(--r);
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
:root[data-theme="dark"] .article-references,
|
|
2793
|
+
html[data-theme="dark"] .article-references {
|
|
2794
|
+
background: var(--card);
|
|
2768
2795
|
}
|
|
2769
2796
|
|
|
2770
|
-
.article-references >
|
|
2797
|
+
.article-references > h2 {
|
|
2771
2798
|
margin: 0 0 14px;
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
font
|
|
2799
|
+
padding: 0;
|
|
2800
|
+
border: 0;
|
|
2801
|
+
font: 600 17px / 1.4 var(--sans);
|
|
2802
|
+
color: var(--ink);
|
|
2775
2803
|
}
|
|
2776
2804
|
|
|
2777
|
-
.article-references
|
|
2778
|
-
|
|
2779
|
-
border-collapse: collapse;
|
|
2780
|
-
font-size: 12px;
|
|
2805
|
+
.article-references > h2::before {
|
|
2806
|
+
content: none;
|
|
2781
2807
|
}
|
|
2782
2808
|
|
|
2783
|
-
.article-references
|
|
2809
|
+
.article-references ol {
|
|
2810
|
+
list-style: none;
|
|
2811
|
+
margin: 0;
|
|
2784
2812
|
padding: 0;
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2813
|
+
counter-reset: ref;
|
|
2814
|
+
font-size: 14px;
|
|
2815
|
+
line-height: 1.6;
|
|
2816
|
+
color: var(--soft);
|
|
2789
2817
|
}
|
|
2790
2818
|
|
|
2791
|
-
.article-references
|
|
2792
|
-
|
|
2793
|
-
|
|
2819
|
+
.article-references li {
|
|
2820
|
+
position: relative;
|
|
2821
|
+
margin: 0 0 6px;
|
|
2822
|
+
padding-left: 2.1em;
|
|
2823
|
+
counter-increment: ref;
|
|
2824
|
+
break-inside: avoid;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
.article-references li::before {
|
|
2828
|
+
content: counter(ref);
|
|
2829
|
+
position: absolute;
|
|
2830
|
+
top: 0.15em;
|
|
2831
|
+
left: 0;
|
|
2832
|
+
width: 1.5em;
|
|
2833
|
+
color: var(--muted);
|
|
2834
|
+
font-size: 12px;
|
|
2794
2835
|
font-variant-numeric: tabular-nums;
|
|
2795
2836
|
text-align: right;
|
|
2796
|
-
white-space: nowrap;
|
|
2797
2837
|
}
|
|
2798
2838
|
|
|
2799
|
-
.article-references
|
|
2800
|
-
.article-references
|
|
2801
|
-
|
|
2802
|
-
padding: 11px 13px;
|
|
2839
|
+
.article-references a,
|
|
2840
|
+
.article-references .ref-title {
|
|
2841
|
+
color: var(--text);
|
|
2803
2842
|
}
|
|
2804
2843
|
|
|
2805
|
-
.article-references
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
text-decoration:
|
|
2844
|
+
.article-references a {
|
|
2845
|
+
text-decoration: underline;
|
|
2846
|
+
text-decoration-thickness: 1px;
|
|
2847
|
+
text-underline-offset: 4px;
|
|
2848
|
+
text-decoration-color: color-mix(in srgb, var(--ink) 35%, transparent);
|
|
2849
|
+
transition: text-decoration-color 0.15s ease;
|
|
2810
2850
|
}
|
|
2811
2851
|
|
|
2812
|
-
.article-references
|
|
2813
|
-
color:
|
|
2814
|
-
text-decoration:
|
|
2852
|
+
.article-references a:hover {
|
|
2853
|
+
text-decoration-color: var(--ink);
|
|
2854
|
+
text-decoration-thickness: 2px;
|
|
2815
2855
|
}
|
|
2816
2856
|
|
|
2817
|
-
.article-references
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2857
|
+
.article-references .ref-domain {
|
|
2858
|
+
font-size: 12px;
|
|
2859
|
+
color: var(--muted);
|
|
2860
|
+
white-space: nowrap;
|
|
2821
2861
|
}
|
|
2822
2862
|
|
|
2823
|
-
|
|
2824
|
-
|
|
2863
|
+
@media (min-width: 900px) {
|
|
2864
|
+
.article-references.is-split ol {
|
|
2865
|
+
columns: 2;
|
|
2866
|
+
column-gap: 32px;
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
@media (max-width: 640px) {
|
|
2871
|
+
.article-references {
|
|
2872
|
+
padding: 18px 16px 12px;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2877
|
+
.article-references a {
|
|
2878
|
+
transition: none;
|
|
2879
|
+
}
|
|
2825
2880
|
}
|
|
2826
2881
|
|
|
2827
2882
|
.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
|
+
};
|
package/app/lib/remark-a2.mjs
CHANGED
|
@@ -18,10 +18,10 @@ const LEGACY_KIND = {
|
|
|
18
18
|
|
|
19
19
|
const ALERT_ICON = {
|
|
20
20
|
note: '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2v4"/><path d="M8 4.8v.1" stroke-width="2"/></svg>',
|
|
21
|
-
tip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="
|
|
22
|
-
important: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 3.25a1 1 0 0 1 1-
|
|
23
|
-
warning: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="
|
|
24
|
-
caution: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.3 1.75h5.4l3.55 3.55v5.4l-3.55 3.55H5.
|
|
21
|
+
tip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.75a4.25 4.25 0 0 0-2.5 7.7c.4.3.6.7.6 1.2v.6h3.8v-.6c0-.5.2-.9.6-1.2A4.25 4.25 0 0 0 8 1.75z"/><path d="M6.2 13.6h3.6"/></svg>',
|
|
22
|
+
important: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 3.25a1.5 1.5 0 0 1 1.5-1.5h8.5a1.5 1.5 0 0 1 1.5 1.5v6.5a1.5 1.5 0 0 1-1.5 1.5H7.5l-3 2.75v-2.75h-.75a1.5 1.5 0 0 1-1.5-1.5z"/><path d="M8 4.5v3"/><path d="M8 9.4v.1" stroke-width="2"/></svg>',
|
|
23
|
+
warning: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.13 2.4a1 1 0 0 1 1.74 0l5.6 9.9a1 1 0 0 1-.87 1.5H2.4a1 1 0 0 1-.87-1.5z"/><path d="M8 6.3v3.1"/><path d="M8 11.4v.1" stroke-width="2"/></svg>',
|
|
24
|
+
caution: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.3 1.75h5.4l3.55 3.55v5.4l-3.55 3.55H5.3L1.75 10.7V5.3z"/><path d="M8 4.8v3.6"/><path d="M8 10.6v.1" stroke-width="2"/></svg>',
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
function meaningfulChildren(node) {
|