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
@@ -0,0 +1,85 @@
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, sameSite) {
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
+ // A link back to this site is an ordinary internal link: the marker goes, no card.
45
+ if (!sameSite(link.url)) marked.push(link);
46
+ }
47
+ parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
48
+ return marked;
49
+ }
50
+
51
+ function walk(parent, source, sameSite) {
52
+ const children = parent.children ?? [];
53
+ for (let index = 0; index < children.length; index += 1) {
54
+ const node = children[index];
55
+ if (node.type === "code" || node.type === "inlineCode" || node.type === "html") continue;
56
+ if (node.type !== "paragraph") {
57
+ if (node.children) {
58
+ // Headings, table cells and the like: drop the marker, no card.
59
+ if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source, sameSite);
60
+ walk(node, source, sameSite);
61
+ }
62
+ continue;
63
+ }
64
+ // Links nested in emphasis count too.
65
+ const marked = takeMarkers(node, source, sameSite);
66
+ for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source, sameSite));
67
+ if (!marked.length) continue;
68
+ const rest = meaningful(node.children);
69
+ if (marked.length === 1 && rest.length === 1 && rest[0] === marked[0]) {
70
+ children[index] = cardNode(marked[0], true);
71
+ continue;
72
+ }
73
+ const cards = marked.map((link) => cardNode(link, false));
74
+ children.splice(index + 1, 0, ...cards);
75
+ index += cards.length;
76
+ }
77
+ }
78
+
79
+ export function remarkLinkCard(options = {}) {
80
+ const sameSite = typeof options?.sameSite === "function" ? options.sameSite : () => null;
81
+ return (tree, file) => {
82
+ const source = typeof file?.value === "string" ? file.value : String(file?.value ?? "");
83
+ walk(tree, source, sameSite);
84
+ };
85
+ }
@@ -22,28 +22,61 @@ export const DEFAULT_VISIBLE_TAGS = 14;
22
22
  const byCount = (left: [string, number], right: [string, number]) =>
23
23
  right[1] - left[1] || left[0].localeCompare(right[0], "zh");
24
24
 
25
- /** Every tag with its post count, most used first. */
25
+ /** Tags match regardless of case: `Rust`, `rust` and `RUST` are one tag. */
26
+ export const tagKey = (tag: string) => tag.trim().toLocaleLowerCase();
27
+
28
+ const postKeys = (post: Tagged) => new Set((post.tags ?? []).map(tagKey).filter(Boolean));
29
+
30
+ /**
31
+ * Every tag with its post count, most used first. Spellings that differ only
32
+ * in case count as one tag, shown in its most common spelling.
33
+ */
26
34
  export function tagCounts(posts: readonly Tagged[]): [string, number][] {
27
35
  const counts = new Map<string, number>();
36
+ const spellings = new Map<string, Map<string, number>>();
28
37
  for (const post of posts) {
29
- for (const tag of new Set(post.tags ?? [])) counts.set(tag, (counts.get(tag) ?? 0) + 1);
38
+ const seen = new Set<string>();
39
+ for (const raw of post.tags ?? []) {
40
+ const tag = raw.trim();
41
+ const key = tagKey(tag);
42
+ if (!key) continue;
43
+ const forms = spellings.get(key) ?? new Map<string, number>();
44
+ forms.set(tag, (forms.get(tag) ?? 0) + 1);
45
+ spellings.set(key, forms);
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ counts.set(key, (counts.get(key) ?? 0) + 1);
49
+ }
30
50
  }
31
- return [...counts.entries()].sort(byCount);
51
+ return [...counts.entries()]
52
+ .map(([key, count]): [string, number] => {
53
+ // Map keeps insertion order, so ties go to the first spelling seen.
54
+ const [label] = [...(spellings.get(key) ?? new Map([[key, 0]])).entries()].reduce((best, form) => (form[1] > best[1] ? form : best));
55
+ return [label, count];
56
+ })
57
+ .sort(byCount);
32
58
  }
33
59
 
34
- /** Selected tags from the repeated URL param, trimmed and de-duplicated, in URL order. */
60
+ /** Selected tags from the repeated URL param, trimmed and de-duplicated (ignoring case), in URL order. */
35
61
  export function selectedTags(values: readonly string[]): string[] {
36
- const seen = new Set<string>();
62
+ const seen = new Map<string, string>();
37
63
  for (const value of values) {
38
64
  const tag = value.trim();
39
- if (tag) seen.add(tag);
65
+ const key = tagKey(tag);
66
+ if (key && !seen.has(key)) seen.set(key, tag);
40
67
  }
41
- return [...seen];
68
+ return [...seen.values()];
69
+ }
70
+
71
+ /** Selected tags written the way the posts write them (`?t=Rust` becomes `rust`); unknown tags stay as given. */
72
+ export function canonicalTags(posts: readonly Tagged[], selected: readonly string[]): string[] {
73
+ const labels = new Map(tagCounts(posts).map(([tag]) => [tagKey(tag), tag]));
74
+ return selected.map((tag) => labels.get(tagKey(tag)) ?? tag);
42
75
  }
43
76
 
44
77
  const hasAll = (post: Tagged, tags: readonly string[]) => {
45
- const own = new Set(post.tags ?? []);
46
- return tags.every((tag) => own.has(tag));
78
+ const own = postKeys(post);
79
+ return tags.every((tag) => own.has(tagKey(tag)));
47
80
  };
48
81
 
49
82
  /** Posts carrying every selected tag (intersection). No selection keeps everything. */
@@ -54,14 +87,15 @@ export function filterByTags<T extends Tagged>(posts: readonly T[], selected: re
54
87
  /** One option per known tag plus any selected tag the posts do not use. */
55
88
  export function tagOptions(posts: readonly Tagged[], selected: readonly string[]): TagOption[] {
56
89
  const current = filterByTags(posts, selected);
57
- const chosen = new Set(selected);
90
+ const chosen = new Set(selected.map(tagKey));
58
91
  const options = tagCounts(posts).map(([tag, total]) => {
59
- const isSelected = chosen.has(tag);
60
- const next = isSelected ? current.length : current.filter((post) => (post.tags ?? []).includes(tag)).length;
92
+ const key = tagKey(tag);
93
+ const isSelected = chosen.has(key);
94
+ const next = isSelected ? current.length : current.filter((post) => postKeys(post).has(key)).length;
61
95
  return { tag, total, next, selected: isSelected, disabled: !isSelected && next === 0 };
62
96
  });
63
97
  for (const tag of selected) {
64
- if (!options.some((option) => option.tag === tag)) {
98
+ if (!options.some((option) => tagKey(option.tag) === tagKey(tag))) {
65
99
  options.push({ tag, total: 0, next: 0, selected: true, disabled: false });
66
100
  }
67
101
  }
@@ -95,9 +129,12 @@ export function visibleTagOptions(options: readonly TagOption[], expanded: boole
95
129
  return { shown, hidden: options.length - shown.length };
96
130
  }
97
131
 
98
- /** Add the tag if absent, remove it if present. */
132
+ /** Add the tag if absent, remove it if present (in any spelling). */
99
133
  export function toggleTag(selected: readonly string[], tag: string): string[] {
100
- return selected.includes(tag) ? selected.filter((item) => item !== tag) : [...selected, tag];
134
+ const key = tagKey(tag);
135
+ return selected.some((item) => tagKey(item) === key)
136
+ ? selected.filter((item) => tagKey(item) !== key)
137
+ : [...selected, tag];
101
138
  }
102
139
 
103
140
  /** `/blog?t=A&t=B`, or `/blog` with no selection. */
package/app/page.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import Link from "next/link";
2
+ import { CoverImage } from "./components/CoverImage";
2
3
  import { homeListDate } from "./lib/a2-meta";
3
4
  import { columns, formatDate, postHref, posts } from "./lib/content";
4
5
  import { githubUrl, siteConfig } from "./site.config";
@@ -49,8 +50,7 @@ export default function Home() {
49
50
  <article className="sh-feature" aria-labelledby="ft">
50
51
  {feature.cover ? (
51
52
  <div className="cover">
52
- {/* eslint-disable-next-line @next/next/no-img-element */}
53
- <img src={feature.cover} alt="" />
53
+ <CoverImage src={feature.cover} image={feature.coverImage} alt="" sizes="(max-width: 700px) calc(100vw - 40px), 604px" priority />
54
54
  </div>
55
55
  ) : null}
56
56
  <div>
@@ -112,7 +112,7 @@ export default function Home() {
112
112
  <div className="cover" style={{ background: "var(--well)" }}>
113
113
  {first.columnCover ? (
114
114
  // eslint-disable-next-line @next/next/no-img-element
115
- <img src={first.columnCover} alt={first.columnCoverAlt ?? ""} />
115
+ <img src={first.columnCover} alt={first.columnCoverAlt ?? ""} loading="lazy" decoding="async" />
116
116
  ) : null}
117
117
  </div>
118
118
  <div className="body">
@@ -3,6 +3,7 @@ import { columnHref, columns, postHref, posts } from "../lib/content";
3
3
  import { getPublishedThoughts } from "../lib/public-thoughts";
4
4
  import { configuredSiteUrl, siteDescription, siteName } from "../lib/site";
5
5
  import { contentForFeed } from "../../ui/core/transform.mjs";
6
+ import { stripCardMarkers } from "../../ui/garden/markdown-links.mjs";
6
7
 
7
8
  const escapeXml = (value: string) =>
8
9
  value
@@ -25,7 +26,7 @@ export async function GET() {
25
26
  })),
26
27
  ...thoughts.map((thought) => ({
27
28
  ...thought,
28
- description: thought.content.replace(/\s+/gu, " ").slice(0, 180),
29
+ description: stripCardMarkers(thought.content).replace(/\s+/gu, " ").slice(0, 180),
29
30
  path: `/thoughts/${thought.slug}`,
30
31
  })),
31
32
  ].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
package/app/styles/a2.css CHANGED
@@ -408,6 +408,34 @@ html[data-theme="dark"] .markdown-body a.ext {
408
408
  text-decoration-thickness: 2px;
409
409
  }
410
410
 
411
+ /* Site favicon before an external link, downloaded at build time. */
412
+ .markdown-body a.ext img.fav {
413
+ display: inline-block;
414
+ width: 14px;
415
+ height: 14px;
416
+ margin: 0 5px 0 0;
417
+ border-radius: 3px;
418
+ vertical-align: -2px;
419
+ object-fit: contain;
420
+ }
421
+
422
+ .markdown-body a.ext .fav-keep {
423
+ white-space: nowrap;
424
+ }
425
+
426
+ /* Many icons are black on transparent; give them a light backing in dark mode. */
427
+ html[data-theme="dark"] .markdown-body img.fav {
428
+ padding: 1px;
429
+ box-sizing: border-box;
430
+ background: #f5f4ed;
431
+ }
432
+
433
+ .markdown-body .table-wrap a.ext img.fav,
434
+ .markdown-body h2 a.ext img.fav,
435
+ .markdown-body h3 a.ext img.fav {
436
+ display: none;
437
+ }
438
+
411
439
  .markdown-body :not(pre) > code {
412
440
  font: 400 var(--fs-small) / 1 var(--mono);
413
441
  color: var(--ink);
@@ -438,9 +466,9 @@ html[data-theme="dark"] .markdown-body a.ext {
438
466
  }
439
467
 
440
468
  .markdown-body.prose {
441
- --al-note: #30302e;
442
- --al-note-bg: #f0eee6;
443
- --al-note-line: rgba(20, 20, 19, 0.18);
469
+ --al-note: #2c55b4;
470
+ --al-note-bg: #eef3fc;
471
+ --al-note-line: rgba(44, 85, 180, 0.22);
444
472
  --al-tip: #2d6a3e;
445
473
  --al-tip-bg: #edf5ef;
446
474
  --al-tip-line: rgba(45, 106, 62, 0.22);
@@ -459,9 +487,9 @@ html[data-theme="dark"] .markdown-body a.ext {
459
487
  :root[data-theme="dark"] .prose,
460
488
  html[data-theme="dark"] .markdown-body.prose,
461
489
  html[data-theme="dark"] .prose {
462
- --al-note: #eeece7;
463
- --al-note-bg: rgba(238, 236, 231, 0.06);
464
- --al-note-line: rgba(238, 236, 231, 0.22);
490
+ --al-note: #93b2f4;
491
+ --al-note-bg: rgba(147, 178, 244, 0.09);
492
+ --al-note-line: rgba(147, 178, 244, 0.28);
465
493
  --al-tip: #8fcb9f;
466
494
  --al-tip-bg: rgba(143, 203, 159, 0.08);
467
495
  --al-tip-line: rgba(143, 203, 159, 0.26);
@@ -1443,16 +1471,32 @@ html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
1443
1471
  .g-flow .fl-e line, .g-flow .fl-e1 { stroke: var(--muted); stroke-width: 1.3; fill: none; }
1444
1472
  .g-flow .fl-ah { fill: var(--muted); }
1445
1473
  .g-flow .gc-stw { fill: rgba(180, 33, 58, 0.08); stroke: rgba(180, 33, 58, 0.45); }
1474
+ /* Link cards: `[text](url){card}`. Title, description and picture come from the build. */
1446
1475
  .prose .link-cards { list-style: none; padding: 0; margin: 24px 0 32px; display: grid; gap: 12px; }
1447
1476
  .prose .link-cards li { margin: 0; padding: 0; }
1448
- .link-card { display: flex; gap: 20px; align-items: stretch; padding: 16px; border: 1px solid var(--rule); border-radius: 12px; color: inherit; text-decoration: none; }
1449
- .prose a.link-card { text-decoration: none; }
1477
+ .link-card { display: flex; gap: 20px; align-items: stretch; padding: 16px; border: 1px solid var(--rule); border-radius: 12px; background: transparent; color: inherit; text-decoration: none; transition: border-color .15s ease, background-color .15s ease; }
1478
+ .prose a.link-card, .markdown-body a.link-card { text-decoration: none; padding-right: 16px; background-image: none; }
1479
+ .link-card:hover { border-color: var(--rule-strong, #cfcbc2); background: var(--card-hover); }
1480
+ .link-card:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
1450
1481
  .lc-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
1451
- .lc-site { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
1452
- .lc-sitename { color: var(--soft); font-weight: 600; }
1482
+ .lc-site { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13px; line-height: 1.5; color: var(--muted); }
1483
+ .markdown-body .lc-site img.fav { display: block; flex: 0 0 auto; width: 14px; height: 14px; margin: 0; border-radius: 3px; object-fit: contain; }
1484
+ .lc-sitename { color: var(--soft); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1453
1485
  .lc-host { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1454
- .lc-arrow { width: 10px; height: 10px; margin-left: auto; background: var(--muted); mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M3 1.5h5.5V7M8.5 1.5 1.5 8.5' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; }
1455
- .lc-title { font-weight: 600; font-size: 16px; line-height: 1.45; color: var(--ink); }
1486
+ .lc-arrow { flex: 0 0 auto; width: 10px; height: 10px; margin-left: auto; background: var(--muted); mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M3 1.5h5.5V7M8.5 1.5 1.5 8.5' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; }
1487
+ .lc-title { font-weight: 600; font-size: 16px; line-height: 1.45; color: var(--ink); text-wrap: pretty; }
1488
+ .lc-desc { font-size: var(--fs-small); line-height: 1.55; color: var(--soft); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
1489
+ .lc-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: auto; padding-top: 4px; font-size: 13px; line-height: 1.5; color: var(--muted); }
1490
+ /* Fixed aspect ratio, so a picture arriving late never moves the text. */
1491
+ .markdown-body img.lc-thumb { display: block; flex: 0 0 auto; width: 168px; max-width: none; height: auto; aspect-ratio: 1.91 / 1; object-fit: cover; align-self: center; margin: 0; border: 1px solid var(--rule); border-radius: 8px; background: var(--well); }
1492
+ @media (max-width: 640px) {
1493
+ .link-card { flex-direction: column-reverse; gap: 12px; padding: 12px; }
1494
+ .prose a.link-card, .markdown-body a.link-card { padding-right: 12px; }
1495
+ .markdown-body img.lc-thumb { width: 100%; }
1496
+ }
1497
+ @media (prefers-reduced-motion: reduce) {
1498
+ .link-card { transition: none; }
1499
+ }
1456
1500
  .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
1457
1501
  .g-chart-t { margin: 0 0 12px; font-weight: 600; color: var(--ink); font-size: 15px; }
1458
1502
  .g-bars { display: grid; grid-template-columns: fit-content(40%) minmax(0, 1fr) max-content; gap: 8px 10px; align-items: center; }
@@ -90,7 +90,7 @@ html[data-theme="dark"] {
90
90
  --accent: var(--blue);
91
91
  --accent-soft: var(--blue-tint);
92
92
  --selection: rgb(238 236 231 / 18%);
93
- --hl: #6b5a2a;
93
+ --hl: #5e4e17;
94
94
  --red: #f29aab;
95
95
  --red-text: #f29aab;
96
96
  --red-tint: rgb(242 154 171 / 12%);
package/bin/garden.mjs CHANGED
@@ -354,6 +354,7 @@ function help() {
354
354
  garden sync 只从 Markdown 生成索引
355
355
  garden new 创建文章草稿
356
356
  garden embed 拉取外链摘要
357
+ garden links 重新拉取外链图标(build 时会自动做)
357
358
  garden init [目录] 创建只含稿和配置的新站点
358
359
  garden check <文章.md> 检查一篇博文的硬性结构
359
360
  garden ui <子命令> Intelligent UI:scan、check、apply、prompt、schema、catalog
@@ -379,8 +380,13 @@ switch (command) {
379
380
  runVinext(["dev", ...rest]);
380
381
  break;
381
382
  case "build":
383
+ // Favicons are refetched on every build; failures fall back to link-assets.json.
384
+ if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/link-assets.mjs");
382
385
  runVinext(["build", ...rest], { copyDist: true });
383
386
  break;
387
+ case "links":
388
+ runNode("scripts/link-assets.mjs");
389
+ break;
384
390
  case "start":
385
391
  runVinext(["start", ...rest]);
386
392
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -7,6 +7,12 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
7
  import { canonicalSiteDate } from "./site-date.mjs";
8
8
  import { contentForFeed, contentToSearchText, transformUiContent } from "../ui/core/transform.mjs";
9
9
  import { countDemoWords } from "../ui/garden/word-count.mjs";
10
+ import { referenceDomain } from "../ui/garden/references.mjs";
11
+ import { linkAssetsForRender } from "./link-assets.mjs";
12
+ import { cardUrls } from "../ui/garden/markdown-links.mjs";
13
+ import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
14
+ import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
15
+ import { coverImageSet, loadSharp } from "./cover-images.mjs";
10
16
 
11
17
  function resolveSiteRoot() {
12
18
  if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
@@ -34,6 +40,9 @@ const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "ht
34
40
  const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
35
41
  ? `${siteUrl}${basePath}`
36
42
  : siteUrl;
43
+ // Full URLs to this address are internal links: no new tab, no card.
44
+ const siteAddress = configuredSiteAddress(siteConfig, process.env);
45
+ const sameSite = sameSiteMatcher(siteAddress, basePath);
37
46
  const siteName = process.env.SITE_NAME ?? siteConfig.name;
38
47
  const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
39
48
 
@@ -95,7 +104,8 @@ const normalizeReferences = (value, sourcePath) => {
95
104
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
96
105
  fail(sourcePath, `${where}.url 必须是 http 或 https 链接`);
97
106
  }
98
- return { title, url };
107
+ const domain = referenceDomain(url);
108
+ return domain ? { title, url, domain } : { title, url };
99
109
  });
100
110
  return references.length ? references : undefined;
101
111
  };
@@ -107,6 +117,8 @@ const isExternalAsset = (value) =>
107
117
  value.startsWith("#");
108
118
 
109
119
  const assetCache = new Map();
120
+ /** Published URL → original file and its copy, for making resized covers later. */
121
+ const assetFiles = new Map();
110
122
 
111
123
  function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
112
124
  if (!value || isExternalAsset(value)) return value;
@@ -136,10 +148,9 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
136
148
  const destination = path.join(generatedMediaRoot, safeRelative);
137
149
  fs.mkdirSync(path.dirname(destination), { recursive: true });
138
150
  fs.copyFileSync(absoluteSource, destination);
139
- assetCache.set(
140
- absoluteSource,
141
- `${basePath}/media/${safeRelative.split(path.sep).map(encodeURIComponent).join("/")}`,
142
- );
151
+ const url = `${basePath}/media/${safeRelative.split(path.sep).map(encodeURIComponent).join("/")}`;
152
+ assetCache.set(absoluteSource, url);
153
+ assetFiles.set(url, { source: absoluteSource, destination });
143
154
  }
144
155
 
145
156
  return `${assetCache.get(absoluteSource)}${suffix}`;
@@ -537,6 +548,19 @@ const a2WordCounts = fs.existsSync(parityDir)
537
548
  }))
538
549
  : {};
539
550
 
551
+ // Resized WebP covers for lists and article heroes; skipped quietly without sharp.
552
+ const sharp = await loadSharp();
553
+ if (sharp) {
554
+ const sets = new Map();
555
+ for (const entry of [...posts, ...columns]) {
556
+ const files = entry.cover ? assetFiles.get(entry.cover) : undefined;
557
+ if (!files) continue;
558
+ if (!sets.has(entry.cover)) sets.set(entry.cover, await coverImageSet({ sharp, ...files, url: entry.cover }));
559
+ const set = sets.get(entry.cover);
560
+ if (set) entry.coverImage = set;
561
+ }
562
+ }
563
+
540
564
  const contentHash = crypto
541
565
  .createHash("sha256")
542
566
  .update(JSON.stringify({ posts, columns, thoughts, a2WordCounts }))
@@ -564,7 +588,7 @@ export type ContentEntry = {
564
588
  publishAt?: string;
565
589
  draft?: boolean;
566
590
  tags?: string[];
567
- references?: { title: string; url?: string }[];
591
+ references?: { title: string; url?: string; domain?: string }[];
568
592
  pinned?: boolean;
569
593
  readingTime?: string;
570
594
  kind: "post" | "column" | "thought";
@@ -578,6 +602,7 @@ export type ContentEntry = {
578
602
  columnCoverAlt?: string;
579
603
  order?: number;
580
604
  cover?: string;
605
+ coverImage?: { src: string; srcset: string; width: number; height: number };
581
606
  mediaType?: "image" | "audio" | "video" | "link" | "text";
582
607
  media?: string | MediaItem[];
583
608
  mediaAlt?: string;
@@ -586,22 +611,39 @@ export type ContentEntry = {
586
611
  linkDescription?: string;
587
612
  };
588
613
 
614
+ export type LinkCardMeta = { title: string; description?: string; siteName?: string; author?: string; publishedTime?: string; image?: string };
615
+ export type LinkAssets = { favicons: Record<string, string>; cards: Record<string, LinkCardMeta> };
616
+
589
617
  export const contentHash = "${contentHash}";
590
618
  export const posts = ${JSON.stringify(posts, null, 2)} as ContentEntry[];
591
619
  export const columns = ${JSON.stringify(columns, null, 2)} as ContentEntry[];
592
620
  export const thoughts = ${JSON.stringify(thoughts, null, 2)} as ContentEntry[];
593
621
  export const a2WordCounts = ${JSON.stringify(a2WordCounts, null, 2)} as Record<string, number>;
622
+ export const linkAssets = ${JSON.stringify(linkAssetsForRender(root, basePath), null, 2)} as LinkAssets;
594
623
  `;
595
624
 
596
625
  fs.mkdirSync(path.dirname(output), { recursive: true });
597
626
  fs.writeFileSync(output, source);
627
+ const fullIndex = buildFullIndex({ posts, columns, thoughts });
598
628
  fs.writeFileSync(
599
629
  siteConfigOutput,
600
630
  `// Generated by garden sync. Edit site.config.yaml instead.
601
631
  export const siteConfig = ${JSON.stringify(siteConfig, null, 2)} as const;
602
632
  export const githubUrl = ${JSON.stringify(`https://github.com/${siteConfig.author.github}`)};
633
+ // Loaded by the header only when search opens; the hash busts stale caches.
634
+ export const searchIndexUrl = ${JSON.stringify(`${basePath}/search-index.json?v=${contentHash}`)};
635
+ // Every body as plain text, fetched in the background after load; the name changes with the text.
636
+ export const searchFull = ${JSON.stringify({ url: `${basePath}/${fullIndex.fileName}`, hash: fullIndex.hash })};
637
+ // Full URLs to this address render as internal links.
638
+ export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath })};
603
639
  `,
604
640
  );
641
+ fs.mkdirSync(path.join(root, "public"), { recursive: true });
642
+ fs.writeFileSync(path.join(root, "public", "search-index.json"), JSON.stringify(buildSearchIndex({ posts, columns, thoughts })));
643
+ for (const name of fs.readdirSync(path.join(root, "public"))) {
644
+ if (/^search-full\.[0-9a-f]+\.json$/u.test(name) && name !== fullIndex.fileName) fs.rmSync(path.join(root, "public", name));
645
+ }
646
+ fs.writeFileSync(path.join(root, "public", fullIndex.fileName), fullIndex.json);
605
647
 
606
648
  const escapeXml = (value) =>
607
649
  value
@@ -662,6 +704,11 @@ const sitemapEntries = [
662
704
  ...columns.map((entry) => `/columns/${entry.column}/${columnEntryPath(entry)}/`),
663
705
  ...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
664
706
  ];
707
+ // Card URLs in published entries: the only pages the link-card Worker may read.
708
+ fs.writeFileSync(
709
+ path.join(publicRoot, "link-allowlist.json"),
710
+ `${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)).filter((url) => !sameSite(url)))].sort() }, null, 2)}\n`,
711
+ );
665
712
  fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
666
713
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
667
714
  <url><loc>${escapeXml(`${publicBaseUrl}${entry}`)}</loc></url>`).join("")}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Resized WebP copies of cover images, made at build time with `sharp` when
3
+ * the site has it. Originals stay untouched; the copies go next to the
4
+ * published original under public/media, with a content hash in the name so
5
+ * they can be cached for a long time.
6
+ */
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ export const COVER_WIDTHS = [480, 960, 1440];
12
+
13
+ export async function loadSharp() {
14
+ try {
15
+ return (await import("sharp")).default;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ const RASTER = /\.(jpe?g|png|webp|avif|tiff?)$/iu;
22
+
23
+ /** Which widths to make: those below the original, plus the original width when it is under the largest. */
24
+ export function coverWidths(naturalWidth, widths = COVER_WIDTHS) {
25
+ if (!naturalWidth) return [];
26
+ const below = widths.filter((width) => width < naturalWidth);
27
+ if (naturalWidth <= widths[widths.length - 1] && !below.includes(naturalWidth)) below.push(naturalWidth);
28
+ return below;
29
+ }
30
+
31
+ /**
32
+ * `{ src, srcset, width, height }` for one published cover, or null when it
33
+ * cannot be resized (no sharp, not a raster image, unreadable file).
34
+ */
35
+ export async function coverImageSet({ sharp, source, destination, url, widths = COVER_WIDTHS, quality = 78 }) {
36
+ if (!sharp || !RASTER.test(source)) return null;
37
+ try {
38
+ const bytes = fs.readFileSync(source);
39
+ const meta = await sharp(bytes).metadata();
40
+ if (!meta.width || !meta.height) return null;
41
+ // EXIF-rotated images swap their sides.
42
+ const rotated = (meta.orientation ?? 1) >= 5;
43
+ const width = rotated ? meta.height : meta.width;
44
+ const height = rotated ? meta.width : meta.height;
45
+ const hash = crypto.createHash("sha256").update(bytes).digest("hex").slice(0, 8);
46
+ const stem = path.basename(destination).replace(/\.[^.]+$/u, "");
47
+ const urlDir = url.slice(0, url.lastIndexOf("/") + 1);
48
+ const variants = [];
49
+ for (const target of coverWidths(width, widths)) {
50
+ const name = `${stem}.${hash}-${target}.webp`;
51
+ const file = path.join(path.dirname(destination), name);
52
+ if (!fs.existsSync(file)) {
53
+ const out = await sharp(bytes).rotate().resize({ width: target, withoutEnlargement: true }).webp({ quality }).toBuffer();
54
+ fs.writeFileSync(file, out);
55
+ }
56
+ variants.push({ width: target, url: `${urlDir}${encodeURIComponent(name)}` });
57
+ }
58
+ if (!variants.length) return null;
59
+ const fallback = variants.find((variant) => variant.width >= 960) ?? variants[variants.length - 1];
60
+ return {
61
+ src: fallback.url,
62
+ srcset: variants.map((variant) => `${variant.url} ${variant.width}w`).join(", "),
63
+ width,
64
+ height,
65
+ };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }