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.
Files changed (40) 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 +87 -19
  9. package/app/components/SiteHeader.tsx +104 -28
  10. package/app/garden-modules.d.ts +17 -1
  11. package/app/globals.css +75 -37
  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-link-card.mjs +83 -0
  18. package/app/lib/tag-filter.ts +52 -15
  19. package/app/page.tsx +3 -3
  20. package/app/rss.xml/route.ts +2 -1
  21. package/app/styles/a2.css +63 -12
  22. package/bin/garden.mjs +6 -0
  23. package/package.json +1 -1
  24. package/scripts/build-content.mjs +47 -6
  25. package/scripts/cover-images.mjs +69 -0
  26. package/scripts/link-assets.mjs +271 -0
  27. package/scripts/link-meta-core.mjs +321 -0
  28. package/ui/core/transform.mjs +4 -3
  29. package/ui/garden/card-refresh.mjs +92 -0
  30. package/ui/garden/markdown-links.mjs +84 -0
  31. package/ui/garden/references.mjs +21 -0
  32. package/ui/garden/search-full.mjs +131 -0
  33. package/ui/garden/search-index.mjs +102 -0
  34. package/ui/garden/search-match.mjs +96 -0
  35. package/ui/preview/dist/garden-ui.esm.js +3 -3
  36. package/ui/preview/dist/garden-ui.iife.js +2 -2
  37. package/worker/og/README.md +59 -0
  38. package/worker/og/handler.mjs +147 -0
  39. package/worker/og/index.mjs +61 -0
  40. 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,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: 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;
@@ -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: 64px;
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 > p {
2780
+ .article-references > h2 {
2771
2781
  margin: 0 0 14px;
2772
- color: var(--accent);
2773
- font-size: 12px;
2774
- font-weight: 700;
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 table {
2778
- width: 100%;
2779
- border-collapse: collapse;
2780
- font-size: 12px;
2788
+ .article-references > h2::before {
2789
+ content: none;
2781
2790
  }
2782
2791
 
2783
- .article-references td {
2792
+ .article-references ol {
2793
+ list-style: none;
2794
+ margin: 0;
2784
2795
  padding: 0;
2785
- border: 1px solid var(--line);
2786
- color: var(--ink-soft);
2787
- text-align: left;
2788
- overflow-wrap: anywhere;
2796
+ counter-reset: ref;
2797
+ font-size: 14px;
2798
+ line-height: 1.6;
2799
+ color: var(--soft);
2789
2800
  }
2790
2801
 
2791
- .article-references td:first-child {
2792
- width: 1%;
2793
- color: var(--accent);
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 td a,
2800
- .article-references td > span {
2801
- display: block;
2802
- padding: 11px 13px;
2822
+ .article-references a,
2823
+ .article-references .ref-title {
2824
+ color: var(--text);
2803
2825
  }
2804
2826
 
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;
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 td a {
2813
- color: inherit;
2814
- text-decoration: none;
2835
+ .article-references a:hover {
2836
+ text-decoration-color: var(--ink);
2837
+ text-decoration-thickness: 2px;
2815
2838
  }
2816
2839
 
2817
- .article-references td a span {
2818
- text-decoration: underline;
2819
- text-decoration-color: var(--accent);
2820
- text-underline-offset: 4px;
2840
+ .article-references .ref-domain {
2841
+ font-size: 12px;
2842
+ color: var(--muted);
2843
+ white-space: nowrap;
2821
2844
  }
2822
2845
 
2823
- .article-references td a:hover span {
2824
- color: var(--accent);
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 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
+ };
@@ -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
+ }