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.
Files changed (44) hide show
  1. package/app/blog/[...path]/page.tsx +2 -2
  2. package/app/blog/page.tsx +5 -2
  3. package/app/columns/[column]/[...path]/page.tsx +2 -6
  4. package/app/components/ArticleReferences.tsx +31 -31
  5. package/app/components/BlogArchive.tsx +20 -28
  6. package/app/components/CoverImage.tsx +34 -0
  7. package/app/components/LinkCardRefresh.tsx +44 -0
  8. package/app/components/MarkdownArticle.tsx +94 -22
  9. package/app/components/SiteHeader.tsx +104 -28
  10. package/app/garden-modules.d.ts +20 -1
  11. package/app/globals.css +96 -41
  12. package/app/layout.tsx +1 -7
  13. package/app/lib/archive.ts +43 -0
  14. package/app/lib/content.ts +5 -39
  15. package/app/lib/generated-content.ts +1 -1
  16. package/app/lib/heading-number.ts +9 -0
  17. package/app/lib/remark-a2.mjs +4 -4
  18. package/app/lib/remark-link-card.mjs +85 -0
  19. package/app/lib/tag-filter.ts +52 -15
  20. package/app/page.tsx +3 -3
  21. package/app/rss.xml/route.ts +2 -1
  22. package/app/styles/a2.css +56 -12
  23. package/app/styles/tokens.css +1 -1
  24. package/bin/garden.mjs +6 -0
  25. package/package.json +1 -1
  26. package/scripts/build-content.mjs +53 -6
  27. package/scripts/cover-images.mjs +69 -0
  28. package/scripts/link-assets.mjs +284 -0
  29. package/scripts/link-meta-core.mjs +321 -0
  30. package/templates/site/site.config.yaml +2 -0
  31. package/ui/core/transform.mjs +4 -3
  32. package/ui/garden/card-refresh.mjs +92 -0
  33. package/ui/garden/markdown-links.mjs +84 -0
  34. package/ui/garden/references.mjs +21 -0
  35. package/ui/garden/same-site.mjs +63 -0
  36. package/ui/garden/search-full.mjs +131 -0
  37. package/ui/garden/search-index.mjs +102 -0
  38. package/ui/garden/search-match.mjs +96 -0
  39. package/ui/preview/dist/garden-ui.esm.js +3 -3
  40. package/ui/preview/dist/garden-ui.iife.js +2 -2
  41. package/worker/og/README.md +59 -0
  42. package/worker/og/handler.mjs +147 -0
  43. package/worker/og/index.mjs +61 -0
  44. 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 Fuse, { type FuseResultMatch } from "fuse.js";
13
+ import type Fuse from "fuse.js";
14
14
  import { MagnifyingGlass, X } from "@phosphor-icons/react";
15
- import { searchRecords } from "../lib/content";
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 FuseResultMatch[] | undefined,
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: (typeof searchRecords)[number],
123
- matches: readonly FuseResultMatch[] | undefined,
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
- const fuse = useMemo(
194
- () =>
195
- new Fuse(searchRecords, {
196
- keys: [
197
- { name: "title", weight: 0.5 },
198
- { name: "description", weight: 0.3 },
199
- { name: "content", weight: 0.15 },
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">没有找到,换个更宽松的关键词试试。</p>
492
+ <p className="empty-state">
493
+ {engine ? "没有找到,换个更宽松的关键词试试。" : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
494
+ </p>
419
495
  )}
420
496
  </div>
421
497
  <footer className="search-footer">
@@ -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: var(--font-geist-mono), "SFMono-Regular", Consolas,
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
- /* A per-line border stays continuous across inline code and descenders. */
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: 1px solid currentColor;
2068
- padding-bottom: 0.1em;
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: 64px;
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 > p {
2797
+ .article-references > h2 {
2771
2798
  margin: 0 0 14px;
2772
- color: var(--accent);
2773
- font-size: 12px;
2774
- font-weight: 700;
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 table {
2778
- width: 100%;
2779
- border-collapse: collapse;
2780
- font-size: 12px;
2805
+ .article-references > h2::before {
2806
+ content: none;
2781
2807
  }
2782
2808
 
2783
- .article-references td {
2809
+ .article-references ol {
2810
+ list-style: none;
2811
+ margin: 0;
2784
2812
  padding: 0;
2785
- border: 1px solid var(--line);
2786
- color: var(--ink-soft);
2787
- text-align: left;
2788
- overflow-wrap: anywhere;
2813
+ counter-reset: ref;
2814
+ font-size: 14px;
2815
+ line-height: 1.6;
2816
+ color: var(--soft);
2789
2817
  }
2790
2818
 
2791
- .article-references td:first-child {
2792
- width: 1%;
2793
- color: var(--accent);
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 td a,
2800
- .article-references td > span {
2801
- display: block;
2802
- padding: 11px 13px;
2839
+ .article-references a,
2840
+ .article-references .ref-title {
2841
+ color: var(--text);
2803
2842
  }
2804
2843
 
2805
- .article-references td:first-child a,
2806
- .article-references td:first-child > span {
2807
- padding-right: 8px;
2808
- color: var(--accent);
2809
- text-decoration: none;
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 td a {
2813
- color: inherit;
2814
- text-decoration: none;
2852
+ .article-references a:hover {
2853
+ text-decoration-color: var(--ink);
2854
+ text-decoration-thickness: 2px;
2815
2855
  }
2816
2856
 
2817
- .article-references td a span {
2818
- text-decoration: underline;
2819
- text-decoration-color: var(--accent);
2820
- text-underline-offset: 4px;
2857
+ .article-references .ref-domain {
2858
+ font-size: 12px;
2859
+ color: var(--muted);
2860
+ white-space: nowrap;
2821
2861
  }
2822
2862
 
2823
- .article-references td a:hover span {
2824
- color: var(--accent);
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 className={geistMono.variable}>
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
+ }
@@ -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 { contentToSearchText } from "../../ui/core/transform.mjs";
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
- /** Pull a leading `2.1` into the section marker and leave the heading sentence. */
69
- export const splitHeadingNumber = (text: string) => {
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
+ };
@@ -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="M6 12.5h4M6.5 14.5h3M8 1.75a4.5 4.5 0 0 0-2.6 8.2c.4.3.6.7.6 1.2v.35h4v-.35c0-.5.2-.9.6-1.2A4.5 4.5 0 0 0 8 1.75Z"/></svg>',
22
- important: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 3.25a1 1 0 0 1 1-1h9.5a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7l-3 2.5v-2.5h-.75a1 1 0 0 1-1-1Z"/><path d="M8 4.75v3"/><path d="M8 9.4v.1" stroke-width="2"/></svg>',
23
- warning: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 1.75 13.25h12.5Z"/><path d="M8 6.5v3.25"/><path d="M8 11.6v.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.3l-3.55-3.55V5.3Z"/><path d="M8 4.75v3.75"/><path d="M8 10.9v.1" stroke-width="2"/></svg>',
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) {