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.
@@ -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: absolute(meta["og:image:secure_url"] || meta["og:image"] || meta["og:image:url"] || meta["twitter:image"], baseUrl),
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
- const { html, url: finalUrl } = await fetchHead(url, options);
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))) links.push({ url: linkUrl(match), card: match[6] === CARD_MARKER });
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)) links.push({ url: match[1], card: false });
69
- for (const match of rest.replace(AUTOLINK, " ").matchAll(BARE)) links.push({ url: match[2], card: false });
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
@@ -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