hd-garden 0.10.0 → 0.10.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/about/page.tsx +1 -1
- package/app/blog/[...path]/page.tsx +11 -80
- package/app/blog/page.tsx +5 -4
- package/app/columns/[column]/[...path]/page.tsx +31 -93
- package/app/columns/page.tsx +6 -4
- package/app/components/HandbookView.tsx +6 -6
- package/app/components/MarkdownArticle.tsx +10 -3
- package/app/components/PageIntro.tsx +9 -4
- package/app/components/PostReadingView.tsx +133 -0
- package/app/components/SeriesEdgeNav.tsx +119 -0
- package/app/layout.tsx +3 -3
- package/app/lib/i18n.ts +6 -2
- package/app/lib/remark-link-card.mjs +11 -12
- package/app/page.tsx +3 -1
- package/app/styles/a2.css +204 -0
- package/app/thoughts/page/[n]/page.tsx +1 -1
- package/app/thoughts/page.tsx +1 -1
- package/docs/handbook/components/link-card.md +5 -5
- package/docs/handbook/en/components/link-card.md +5 -5
- package/package.json +1 -1
- package/public/og.png +0 -0
- package/scripts/build-content.mjs +2 -3
- package/scripts/link-assets.mjs +7 -5
- package/scripts/link-meta-core.mjs +40 -3
- package/ui/garden/markdown-links.mjs +44 -8
- package/ui/garden/site-nav.mjs +3 -1
|
@@ -184,6 +184,17 @@ const absolute = (value, base) => {
|
|
|
184
184
|
}
|
|
185
185
|
};
|
|
186
186
|
|
|
187
|
+
|
|
188
|
+
/** Site brand OG path `/og.png` (any host) — not an article cover for link cards. */
|
|
189
|
+
export function isSiteBrandOgImage(url) {
|
|
190
|
+
if (!url) return false;
|
|
191
|
+
try {
|
|
192
|
+
return new URL(url).pathname === "/og.png";
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
187
198
|
/** Title, description, image, site name, author, date and icon candidates from a page's head. */
|
|
188
199
|
export function parseHead(html, baseUrl) {
|
|
189
200
|
const head = (/<head[\s>][\s\S]*?(<\/head>|$)/iu.exec(html)?.[0]) ?? html.slice(0, 200_000);
|
|
@@ -210,10 +221,11 @@ export function parseHead(html, baseUrl) {
|
|
|
210
221
|
const published = meta["article:published_time"] || meta["og:published_time"] || meta.date || meta.datepublished || meta["parsely-pub-date"] || "";
|
|
211
222
|
const publishedTime = published && !Number.isNaN(Date.parse(published)) ? new Date(Date.parse(published)).toISOString().slice(0, 10) : "";
|
|
212
223
|
const author = meta["article:author"] || meta.author || meta["twitter:creator"] || "";
|
|
224
|
+
const imageCandidate = absolute(meta["og:image:secure_url"] || meta["og:image"] || meta["og:image:url"] || meta["twitter:image"], baseUrl);
|
|
213
225
|
return {
|
|
214
226
|
title: clean(meta["og:title"] || meta["twitter:title"] || title, 200),
|
|
215
227
|
description: clean(meta["og:description"] || meta["twitter:description"] || meta.description, 300),
|
|
216
|
-
image:
|
|
228
|
+
image: isSiteBrandOgImage(imageCandidate) ? "" : imageCandidate,
|
|
217
229
|
siteName: clean(meta["og:site_name"] || meta["application-name"], 80),
|
|
218
230
|
// article:author is sometimes a profile URL; only keep readable names.
|
|
219
231
|
author: /^https?:\/\//iu.test(author) ? "" : clean(author, 80),
|
|
@@ -222,6 +234,18 @@ export function parseHead(html, baseUrl) {
|
|
|
222
234
|
};
|
|
223
235
|
}
|
|
224
236
|
|
|
237
|
+
/** Target of an HTML meta refresh, when the page asks the browser to move. */
|
|
238
|
+
export function metaRefreshTarget(html, baseUrl) {
|
|
239
|
+
for (const tag of html.match(/<meta\b[^>]*>/giu) ?? []) {
|
|
240
|
+
const attrs = attributes(tag);
|
|
241
|
+
if ((attrs["http-equiv"] || "").toLowerCase() !== "refresh") continue;
|
|
242
|
+
const match = /(?:^|;)\s*url\s*=\s*["']?([^"'>\s]+)/iu.exec(attrs.content || "");
|
|
243
|
+
const target = absolute(match?.[1] ?? "", baseUrl);
|
|
244
|
+
if (target) return target;
|
|
245
|
+
}
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
|
|
225
249
|
/** A page's head, fetched safely: HTML only, at most maxBytes, stopping at </head>. */
|
|
226
250
|
export async function fetchHead(url, { fetch: fetcher, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_HTML_BYTES } = {}) {
|
|
227
251
|
const { response, url: finalUrl } = await safeFetch(url, { fetch: fetcher, timeoutMs });
|
|
@@ -232,9 +256,22 @@ export async function fetchHead(url, { fetch: fetcher, timeoutMs = DEFAULT_TIMEO
|
|
|
232
256
|
return { html: new TextDecoder().decode(bytes), url: finalUrl };
|
|
233
257
|
}
|
|
234
258
|
|
|
235
|
-
/** Open Graph fields for a page. */
|
|
259
|
+
/** Open Graph fields for a page. Follows HTML meta refresh the way a browser would. */
|
|
236
260
|
export async function fetchPageMeta(url, options = {}) {
|
|
237
|
-
|
|
261
|
+
let current = url;
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
264
|
+
if (seen.has(current)) break;
|
|
265
|
+
seen.add(current);
|
|
266
|
+
const { html, url: finalUrl } = await fetchHead(current, options);
|
|
267
|
+
const refresh = metaRefreshTarget(html, finalUrl);
|
|
268
|
+
if (refresh && refresh !== finalUrl && !seen.has(refresh)) {
|
|
269
|
+
current = refresh;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
return { ...parseHead(html, finalUrl), url: finalUrl };
|
|
273
|
+
}
|
|
274
|
+
const { html, url: finalUrl } = await fetchHead(current, options);
|
|
238
275
|
return { ...parseHead(html, finalUrl), url: finalUrl };
|
|
239
276
|
}
|
|
240
277
|
|
|
@@ -50,32 +50,68 @@ function mapInline(text, visit) {
|
|
|
50
50
|
|
|
51
51
|
// Inline links read as CommonMark reads them (ui/garden/markdown-destination.mjs):
|
|
52
52
|
// `[t](<https://e.com/a (1)>)` and `[t](https://e.com/Trie_(x))` are whole addresses.
|
|
53
|
-
// Groups: 1 `!`, 3 or 4 the address, 6 `{card}`.
|
|
53
|
+
// Groups: 1 `!`, 2 label, 3 or 4 the address, 6 `{card}`.
|
|
54
54
|
const LINK = new RegExp(`${INLINE_LINK_SOURCE}(\\{card\\}|\\{source\\})?`, "giu");
|
|
55
55
|
const linkUrl = (match) => match[3] ?? match[4];
|
|
56
56
|
const isWebUrl = (url) => /^https?:\/\//iu.test(url);
|
|
57
57
|
const AUTOLINK = /<(https?:\/\/[^\s>]+)>/giu;
|
|
58
58
|
const BARE = /(^|[\s((::,,。])(https?:\/\/[^\s<>()()\],。、]+[^\s<>()()\].,;:!?,。、;:!?'"])/giu;
|
|
59
59
|
|
|
60
|
+
/** The marker that asks for a link card: `[text](url){card}`, nothing in between. */
|
|
61
|
+
export const CARD_MARKER = "{card}";
|
|
62
|
+
/** The marker that draws a link as an inline source chip: `[site](url){source}`. */
|
|
63
|
+
export const SOURCE_MARKER = "{source}";
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A single-line paragraph whose only content is a web URL: bare, <autolink>,
|
|
68
|
+
* or `[url](url)` (optional `{card}`). Those render as link cards and share
|
|
69
|
+
* the OG fetch path with `{card}` markers.
|
|
70
|
+
*/
|
|
71
|
+
function standaloneUrl(line) {
|
|
72
|
+
const trimmed = String(line ?? "").trim();
|
|
73
|
+
if (!trimmed || /\n/u.test(trimmed)) return "";
|
|
74
|
+
// Fresh regex: a shared /g lastIndex would skip later matchAll calls.
|
|
75
|
+
const link = new RegExp(LINK.source, LINK.flags).exec(trimmed);
|
|
76
|
+
if (link && link.index === 0 && link[0].length === trimmed.length && link[1] !== "!") {
|
|
77
|
+
const url = linkUrl(link);
|
|
78
|
+
const label = link[2] ?? "";
|
|
79
|
+
if (isWebUrl(url) && (link[6] === CARD_MARKER || label === url)) return url;
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
const auto = /^<(https?:\/\/[^\s>]+)>$/iu.exec(trimmed);
|
|
83
|
+
if (auto) return auto[1];
|
|
84
|
+
const bare = /^(https?:\/\/[^\s<>()()]+)$/iu.exec(trimmed);
|
|
85
|
+
return bare && isWebUrl(bare[1]) ? bare[1] : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
/** Every external link in the body, in order: `{ url, card }`. Images are left out. */
|
|
61
89
|
export function markdownLinks(markdown) {
|
|
62
90
|
const links = [];
|
|
91
|
+
const standalone = new Set();
|
|
63
92
|
mapOutsideCode(markdown, (text) => {
|
|
93
|
+
for (const block of text.split(/\n{2,}/u)) {
|
|
94
|
+
const url = standaloneUrl(block);
|
|
95
|
+
if (url) standalone.add(url);
|
|
96
|
+
}
|
|
64
97
|
for (const match of text.matchAll(LINK)) {
|
|
65
|
-
if (match[1] !== "!" && isWebUrl(linkUrl(match)))
|
|
98
|
+
if (match[1] !== "!" && isWebUrl(linkUrl(match))) {
|
|
99
|
+
const url = linkUrl(match);
|
|
100
|
+
links.push({ url, card: match[6] === CARD_MARKER || standalone.has(url) });
|
|
101
|
+
}
|
|
66
102
|
}
|
|
67
103
|
const rest = text.replace(LINK, " ");
|
|
68
|
-
for (const match of rest.matchAll(AUTOLINK))
|
|
69
|
-
|
|
104
|
+
for (const match of rest.matchAll(AUTOLINK)) {
|
|
105
|
+
links.push({ url: match[1], card: standalone.has(match[1]) });
|
|
106
|
+
}
|
|
107
|
+
for (const match of rest.replace(AUTOLINK, " ").matchAll(BARE)) {
|
|
108
|
+
links.push({ url: match[2], card: standalone.has(match[2]) });
|
|
109
|
+
}
|
|
70
110
|
return text;
|
|
71
111
|
});
|
|
72
112
|
return links;
|
|
73
113
|
}
|
|
74
114
|
|
|
75
|
-
/** The marker that asks for a link card: `[text](url){card}`, nothing in between. */
|
|
76
|
-
export const CARD_MARKER = "{card}";
|
|
77
|
-
/** The marker that draws a link as an inline source chip: `[site](url){source}`. */
|
|
78
|
-
export const SOURCE_MARKER = "{source}";
|
|
79
115
|
|
|
80
116
|
/**
|
|
81
117
|
* The same Markdown without `{card}` markers, for excerpts, RSS and the search
|
package/ui/garden/site-nav.mjs
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* The header's site navigation. Hrefs carry the trailing slash: a static
|
|
3
3
|
* export serves these pages at /blog/ and so on, and the host answers /blog
|
|
4
4
|
* with a 301 first (new tab, no JavaScript, crawlers).
|
|
5
|
+
*
|
|
6
|
+
* /tags/ stays as a deep route (article tag chips still link there) but is
|
|
7
|
+
* not listed in the primary nav.
|
|
5
8
|
*/
|
|
6
9
|
export const SITE_NAV = [
|
|
7
10
|
{ href: "/blog/", label: "文章" },
|
|
8
11
|
{ href: "/thoughts/", label: "随想" },
|
|
9
12
|
{ href: "/columns/", label: "专栏" },
|
|
10
|
-
{ href: "/tags/", label: "标签" },
|
|
11
13
|
{ href: "/about/", label: "关于" },
|
|
12
14
|
];
|
|
13
15
|
|