hd-garden 0.10.1 → 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.
@@ -45,10 +45,10 @@ export async function generateMetadata({
45
45
  images: post.cover ? [{ url: post.cover, alt: post.title }] : undefined,
46
46
  },
47
47
  twitter: {
48
- card: "summary_large_image",
48
+ card: post.cover ? "summary_large_image" : "summary",
49
49
  title: post.title,
50
50
  description: post.description,
51
- images: post.cover ? [post.cover] : ["/og.png"],
51
+ images: post.cover ? [post.cover] : undefined,
52
52
  },
53
53
  } satisfies Metadata;
54
54
  }
@@ -55,10 +55,10 @@ export async function generateMetadata({
55
55
  images: entry.cover ? [{ url: entry.cover, alt: entry.title }] : undefined,
56
56
  },
57
57
  twitter: {
58
- card: "summary_large_image",
58
+ card: entry.cover ? "summary_large_image" : "summary",
59
59
  title,
60
60
  description: entry.description,
61
- images: entry.cover ? [entry.cover] : ["/og.png"],
61
+ images: entry.cover ? [entry.cover] : undefined,
62
62
  },
63
63
  } satisfies Metadata;
64
64
  }
@@ -64,8 +64,8 @@ export async function handbookOverviewMetadata(lang: HandbookLang, indexDoc: Gar
64
64
  images: [
65
65
  {
66
66
  url: "/og.png",
67
- width: 1731,
68
- height: 909,
67
+ width: 1200,
68
+ height: 630,
69
69
  alt: imageAlt,
70
70
  },
71
71
  ],
@@ -94,8 +94,8 @@ export async function handbookDocMetadata(lang: HandbookLang, doc: GardenDocEntr
94
94
  images: [
95
95
  {
96
96
  url: "/og.png",
97
- width: 1731,
98
- height: 909,
97
+ width: 1200,
98
+ height: 630,
99
99
  alt: fullTitle,
100
100
  },
101
101
  ],
@@ -125,8 +125,8 @@ export async function handbookDocMetadata(lang: HandbookLang, doc: GardenDocEntr
125
125
  images: [
126
126
  {
127
127
  url: "/og.png",
128
- width: 1731,
129
- height: 909,
128
+ width: 1200,
129
+ height: 630,
130
130
  alt: `${siteConfig.name} · ${docTitle}`,
131
131
  },
132
132
  ],
@@ -292,7 +292,7 @@ export function MarkdownArticle({ content, numbering, lang, fieldTables, refDemo
292
292
  <FaviconProvider favicons={linkAssets?.favicons}>
293
293
  <div className="markdown-body prose">
294
294
  <ReactMarkdown
295
- remarkPlugins={[...(refDemos ? [remarkRefDemos] : []), ...markdownPlugins, [remarkLinkCard, { sameSite }], [remarkA2, { imageSize: (src: string) => lookupImageSize(imageSizes, src), lang: lang ?? siteLang }]]}
295
+ remarkPlugins={[...(refDemos ? [remarkRefDemos] : []), ...markdownPlugins, remarkLinkCard, [remarkA2, { imageSize: (src: string) => lookupImageSize(imageSizes, src), lang: lang ?? siteLang }]]}
296
296
  rehypePlugins={[
297
297
  rehypeRaw,
298
298
  rehypeKatex,
@@ -311,11 +311,18 @@ export function MarkdownArticle({ content, numbering, lang, fieldTables, refDemo
311
311
  const only = nodes.length === 1 && isValidElement<{ href?: string; children?: ReactNode }>(nodes[0]) ? nodes[0] : null;
312
312
  const href = only?.props.href;
313
313
  const text = only ? headingText(only.props.children).trim() : "";
314
- if (href && text === href && /^https?:\/\//iu.test(href) && !sameSite(href)) return <LinkCard href={href} />;
314
+ // Bare http(s) URL alone in a paragraph → link card (same path for every jump link).
315
+ if (href && text === href && /^https?:\/\//iu.test(href)) return <LinkCard href={href} />;
315
316
  return <p {...domProps}>{children}</p>;
316
317
  },
317
318
  a: ({ href: written, children, ...props }) => {
318
- const { href, external } = articleLink(written, sameSite);
319
+ const label = headingText(children).trim();
320
+ // Bare URL jump links stay absolute so the paragraph card path is one pipeline
321
+ // for same-site and external alike. Titled same-site prose still uses Link.
322
+ const bareJump = Boolean(written && label === written && /^https?:\/\//iu.test(written));
323
+ const { href, external } = bareJump
324
+ ? { href: written as string, external: true }
325
+ : articleLink(written, sameSite);
319
326
  const domProps = { ...props };
320
327
  Reflect.deleteProperty(domProps, "node");
321
328
  // A link to another page of the site navigates in place, like the header and lists do.
package/app/layout.tsx CHANGED
@@ -49,9 +49,9 @@ export async function generateMetadata(): Promise<Metadata> {
49
49
  images: [
50
50
  {
51
51
  url: "/og.png",
52
- width: 1731,
53
- height: 909,
54
- alt: `${siteConfig.name} · ${siteConfig.pages.home.title.replace("\n", "")}`,
52
+ width: 1200,
53
+ height: 630,
54
+ alt: `${siteConfig.name} · ${siteConfig.tagline} · ${siteConfig.pages.home.title.replace("\n", "")}`,
55
55
  },
56
56
  ],
57
57
  },
@@ -11,7 +11,8 @@ import { toString } from "mdast-util-to-string";
11
11
  * paragraph (inside the same list item or quote).
12
12
  *
13
13
  * Cards are `garden-linkcard` elements carrying `data-url`, `data-text` and,
14
- * for the standalone form, `data-standalone`.
14
+ * for the standalone form, `data-standalone`. Every http(s) jump link that
15
+ * asks for a card uses this same path — same-site and external alike.
15
16
  */
16
17
 
17
18
  const MARKER = "{card}";
@@ -32,7 +33,7 @@ const meaningful = (children) =>
32
33
  children.filter((child) => !(child.type === "text" && !child.value.trim()) && child.type !== "break");
33
34
 
34
35
  /** Take the markers out of one parent's inline children; return the marked links. */
35
- function takeMarkers(parent, source, sameSite) {
36
+ function takeMarkers(parent, source) {
36
37
  const marked = [];
37
38
  const children = parent.children ?? [];
38
39
  for (let index = 0; index < children.length - 1; index += 1) {
@@ -41,14 +42,13 @@ function takeMarkers(parent, source, sameSite) {
41
42
  if (link.type !== "link" || next.type !== "text" || !next.value.startsWith(MARKER)) continue;
42
43
  if (!isHttp(link.url) || !writtenAsLink(link, source)) continue;
43
44
  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);
45
+ marked.push(link);
46
46
  }
47
47
  parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
48
48
  return marked;
49
49
  }
50
50
 
51
- function walk(parent, source, sameSite) {
51
+ function walk(parent, source) {
52
52
  const children = parent.children ?? [];
53
53
  for (let index = 0; index < children.length; index += 1) {
54
54
  const node = children[index];
@@ -56,14 +56,14 @@ function walk(parent, source, sameSite) {
56
56
  if (node.type !== "paragraph") {
57
57
  if (node.children) {
58
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);
59
+ if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source);
60
+ walk(node, source);
61
61
  }
62
62
  continue;
63
63
  }
64
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));
65
+ const marked = takeMarkers(node, source);
66
+ for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source));
67
67
  if (!marked.length) continue;
68
68
  const rest = meaningful(node.children);
69
69
  if (marked.length === 1 && rest.length === 1 && rest[0] === marked[0]) {
@@ -97,11 +97,10 @@ function markSources(parent, source) {
97
97
  parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
98
98
  }
99
99
 
100
- export function remarkLinkCard(options = {}) {
101
- const sameSite = typeof options?.sameSite === "function" ? options.sameSite : () => null;
100
+ export function remarkLinkCard() {
102
101
  return (tree, file) => {
103
102
  const source = typeof file?.value === "string" ? file.value : String(file?.value ?? "");
104
103
  markSources(tree, source);
105
- walk(tree, source, sameSite);
104
+ walk(tree, source);
106
105
  };
107
106
  }
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: 外部链接卡片
2
+ title: 链接卡片
3
3
  navTitle: 链接卡片
4
- description: 把一条外部链接渲染成带网站名、标题和简介的卡片。
4
+ description: 把一条跳转链接渲染成带网站名、标题和简介的卡片。
5
5
  section: components
6
6
  order: 43
7
7
  ---
8
8
 
9
- 单独占一段的网址,或链接末尾紧跟 `{card}`,会渲染成链接卡片。正文里的普通外部链接不会变成卡片,例如 [Typora](https://typora.io/):只在文字前加站点 Favicon,右上角标 `↗`,图标与第一个字留在同一行。
9
+ 单独占一段的网址,或链接末尾紧跟 `{card}`,会渲染成链接卡片(同站与外站同一套逻辑)。正文里带标题的普通链接不会变成卡片,例如 [Typora](https://typora.io/):外站只在文字前加站点 Favicon,右上角标 `↗`,图标与第一个字留在同一行;同站标题链接仍在站内跳转。
10
10
 
11
11
  - **什么时候用**:希望读者点开之前先看到网站名、标题和简介。
12
12
  - **什么时候不用**:句子里只要标出出处时用[来源胶囊](/garden/components/source);要自己写几条要点时用[知识卡片](/garden/components/card)。
@@ -41,9 +41,9 @@ https://spec.commonmark.org/
41
41
 
42
42
  [KaTeX 支持的函数](https://katex.org/docs/supported.html){card}
43
43
 
44
- `{card}` 写在代码里会原样显示,例如 `[文字](https://example.com){card}`。标记和 `)` 之间有空格时也不会变成卡片。指向本站的链接会去掉标记,仍是站内链接。
44
+ `{card}` 写在代码里会原样显示,例如 `[文字](https://example.com){card}`。标记和 `)` 之间有空格时也不会变成卡片。同站与外站的跳链共用同一套卡片路径。
45
45
 
46
- 构建时读取正文里的 `{card}` 链接,把对方页面的标题、简介和封面记入 `link-assets.json`。单独一行的网址同样变成卡片,标题和简介用同一份记录;这个网址没有以 `{card}` 出现过时,卡片上显示网址本身。抓取失败就沿用上次的结果,构建不会因此中断。只刷新这些信息时运行 `npx garden links`。离线构建时把 `GARDEN_OFFLINE` 设为 `1` 会跳过拉取。
46
+ 构建时读取正文里的 `{card}` 链接,以及单独成段的网址,把对方页面的标题、简介和封面记入 `link-assets.json`(同站与外站同一套抓取;HTML meta refresh 会跟着跳到目标页再读 OG)。抓取失败就沿用上次的结果,构建不会因此中断。只刷新这些信息时运行 `npx garden links`。离线构建时把 `GARDEN_OFFLINE` 设为 `1` 会跳过拉取。
47
47
 
48
48
  随想 Front Matter 里的 `type: link` 用你写的 `title`、`description` 和 `image`,字段见[写文章与随想](/garden/writing)。
49
49
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: External Link Card
2
+ title: Link Card
3
3
  navTitle: Link card
4
- description: Render an external URL as a card with the site name, title, and summary.
4
+ description: Render a jump URL as a card with the site name, title, and summary.
5
5
  section: components
6
6
  order: 43
7
7
  ---
8
8
 
9
- A URL on its own paragraph, or a link followed immediately by `{card}`, renders as a link card. An ordinary external link in prose stays a link, such as [Typora](https://typora.io/): it shows the site favicon before the text and a `↗` at the upper right, and the icon stays on the same line as the first word.
9
+ A URL on its own paragraph, or a link followed immediately by `{card}`, renders as a link card (same-site and external share one path). An ordinary titled link in prose stays a link, such as [Typora](https://typora.io/): an external one shows the site favicon before the text and a `↗` at the upper right; a titled same-site link still navigates in place.
10
10
 
11
11
  - **When to use**: The reader should see the site name, title, and summary before opening the page.
12
12
  - **When to skip**: Use a [source chip](/garden/en/components/source) to cite a site inside a sentence, or a [reference card](/garden/en/components/card) when you are writing the takeaways yourself.
@@ -41,9 +41,9 @@ When the paragraph is only the marked link, that line becomes the card:
41
41
 
42
42
  [Supported KaTeX functions](https://katex.org/docs/supported.html){card}
43
43
 
44
- `{card}` inside code stays as written, for example `[text](https://example.com){card}`. A space between `)` and the marker also leaves an ordinary link. A link back to this site drops the marker and stays an internal link.
44
+ `{card}` inside code stays as written, for example `[text](https://example.com){card}`. A space between `)` and the marker also leaves an ordinary link. Same-site and external jump links share one card path.
45
45
 
46
- At build time, Garden reads `{card}` links in the body and stores the page title, summary, and image in `link-assets.json`. A URL on its own line becomes a card too, using that same record; when the URL has never appeared as `{card}`, the card shows the URL itself. A failed fetch keeps the previous record, and the build continues. Run `npx garden links` to refresh just these records. Set `GARDEN_OFFLINE` to `1` to skip the fetch during an offline build.
46
+ At build time, Garden reads `{card}` links and URLs that stand alone as a paragraph, and stores the page title, summary, and image in `link-assets.json` (same-site and external share one fetch path; HTML meta refresh is followed to the target before reading OG). A failed fetch keeps the previous record, and the build continues. Run `npx garden links` to refresh just these records. Set `GARDEN_OFFLINE` to `1` to skip the fetch during an offline build.
47
47
 
48
48
  A thought can also carry an external link in Front Matter with `type: link`, using the `title`, `description`, and `image` you write. Those fields are in [Writing Posts & Thoughts](/garden/en/writing).
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
package/public/og.png CHANGED
Binary file
@@ -12,7 +12,7 @@ import { referenceDomain } from "../ui/garden/references.mjs";
12
12
  import { linkAssetsForRender } from "./link-assets.mjs";
13
13
  import { cardUrls } from "../ui/garden/markdown-links.mjs";
14
14
  import { formatInlineLink, replaceInlineLinks } from "../ui/garden/markdown-destination.mjs";
15
- import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
15
+ import { configuredSiteAddress } from "../ui/garden/same-site.mjs";
16
16
  import { resolveBrand } from "./brand.mjs";
17
17
  import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
18
18
  import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
@@ -74,7 +74,6 @@ const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
74
74
  : siteUrl;
75
75
  // Full URLs to this address are internal links: no new tab, no card.
76
76
  const siteAddress = configuredSiteAddress(siteConfig, process.env);
77
- const sameSite = sameSiteMatcher(siteAddress, basePath);
78
77
  const siteName = process.env.SITE_NAME ?? siteConfig.name;
79
78
  const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
80
79
  const siteLang = /^en/iu.test(String(siteConfig.locale ?? "")) ? "en" : "zh";
@@ -997,7 +996,7 @@ const sitemapEntries = [
997
996
  // Card URLs in published entries: the only pages the link-card Worker may read.
998
997
  fs.writeFileSync(
999
998
  path.join(publicRoot, "link-allowlist.json"),
1000
- `${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)).filter((url) => !sameSite(url)))].sort() }, null, 2)}\n`,
999
+ `${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)))].sort() }, null, 2)}\n`,
1001
1000
  );
1002
1001
  fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
1003
1002
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
@@ -16,7 +16,7 @@ import { isIP } from "node:net";
16
16
  import path from "node:path";
17
17
  import { pathToFileURL } from "node:url";
18
18
  import matter from "gray-matter";
19
- import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, linkHost } from "./link-meta-core.mjs";
19
+ import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, isSiteBrandOgImage, linkHost } from "./link-meta-core.mjs";
20
20
  import { parse as parseYaml } from "yaml";
21
21
  import { markdownLinks } from "../ui/garden/markdown-links.mjs";
22
22
  import { gardenSourceUrls } from "../ui/garden/source-icon.mjs";
@@ -60,10 +60,11 @@ function readSiteConfig(siteRoot) {
60
60
  }
61
61
  }
62
62
 
63
- /** External links across every post: the sites (for favicons) and the URLs. */
63
+ /** Jump links across every post: the sites (for favicons) and the URLs. */
64
64
  export function collectSiteLinks(siteRoot, env = process.env) {
65
65
  const config = readSiteConfig(siteRoot);
66
- // Links back to this site are internal: no favicon, no card.
66
+ // Ordinary same-site prose links need no favicon fetch. Card URLs — same-site
67
+ // or external — share one OG/link-assets path.
67
68
  const sameSite = sameSiteMatcher(configuredSiteAddress(config, env), env.NEXT_PUBLIC_BASE_PATH ?? "");
68
69
  const hosts = new Map();
69
70
  const cards = new Set();
@@ -71,7 +72,8 @@ export function collectSiteLinks(siteRoot, env = process.env) {
71
72
  for (const file of markdownFiles(path.join(siteRoot, "content"))) {
72
73
  const { content } = matter(fs.readFileSync(file, "utf8"));
73
74
  for (const { url, card } of markdownLinks(content)) {
74
- if (!isPublicUrl(url) || sameSite(url)) continue;
75
+ if (!isPublicUrl(url)) continue;
76
+ if (sameSite(url) && !card) continue;
75
77
  urls.add(url);
76
78
  if (card) cards.add(url);
77
79
  const host = linkHost(url);
@@ -184,7 +186,7 @@ export async function refreshLinkAssets({
184
186
  const meta = await fetchPageMeta(url, { fetch: fetchImpl, timeoutMs });
185
187
  if (!meta.title) return keepCachedCard(url);
186
188
  let image;
187
- if (meta.image) {
189
+ if (meta.image && !isSiteBrandOgImage(meta.image)) {
188
190
  try {
189
191
  const picture = await shrinkImage(await fetchImage(meta.image, { fetch: fetchImpl, timeoutMs: timeoutMs * 2 }));
190
192
  const name = `${digest(Buffer.from(url))}-${digest(picture.bytes)}.${picture.ext}`;
@@ -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