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
|
@@ -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
|
+
}
|
package/app/lib/tag-filter.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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()]
|
|
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
|
|
62
|
+
const seen = new Map<string, string>();
|
|
37
63
|
for (const value of values) {
|
|
38
64
|
const tag = value.trim();
|
|
39
|
-
|
|
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 =
|
|
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
|
|
60
|
-
const
|
|
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
|
-
|
|
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
|
-
{
|
|
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">
|
package/app/rss.xml/route.ts
CHANGED
|
@@ -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: #
|
|
442
|
-
--al-note-bg: #
|
|
443
|
-
--al-note-line: rgba(
|
|
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: #
|
|
463
|
-
--al-note-bg: rgba(
|
|
464
|
-
--al-note-line: rgba(
|
|
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-
|
|
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; }
|
package/app/styles/tokens.css
CHANGED
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
|
@@ -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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|