hd-garden 0.5.1 → 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.
@@ -21,11 +21,15 @@ import { CodeCopy } from "./garden/chrome";
21
21
  import { CodeDiff } from "./garden/chrome";
22
22
  import { GardenBlock } from "./garden/GardenBlock";
23
23
  import { linkAssets } from "@garden/generated-content";
24
- import { siteConfig } from "@garden/site-config";
24
+ import { siteConfig, siteLinks } from "@garden/site-config";
25
+ import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
25
26
  import { LinkCardRefresh } from "./LinkCardRefresh";
26
27
  import { linkHost } from "../../scripts/link-meta-core.mjs";
27
28
  import { cardDate } from "../../ui/garden/card-refresh.mjs";
28
29
 
30
+ // Full URLs back to this site are internal links, written as root-relative paths.
31
+ const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
32
+
29
33
  /** The site's favicon, downloaded at build time; nothing when there is none. */
30
34
  function Favicon({ href }: { href: string }) {
31
35
  const src = linkAssets?.favicons?.[linkHost(href)];
@@ -221,7 +225,7 @@ export function MarkdownArticle({ content }: { content: string }) {
221
225
  return (
222
226
  <div className="markdown-body prose">
223
227
  <ReactMarkdown
224
- remarkPlugins={[...markdownPlugins, remarkLinkCard, remarkA2]}
228
+ remarkPlugins={[...markdownPlugins, [remarkLinkCard, { sameSite }], remarkA2]}
225
229
  rehypePlugins={[
226
230
  rehypeRaw,
227
231
  rehypeKatex,
@@ -237,11 +241,11 @@ export function MarkdownArticle({ content }: { content: string }) {
237
241
  const only = nodes.length === 1 && isValidElement<{ href?: string; children?: ReactNode }>(nodes[0]) ? nodes[0] : null;
238
242
  const href = only?.props.href;
239
243
  const text = only ? headingText(only.props.children).trim() : "";
240
- if (href && text === href && /^https?:\/\//iu.test(href)) return <LinkCard href={href} />;
244
+ if (href && text === href && /^https?:\/\//iu.test(href) && !sameSite(href)) return <LinkCard href={href} />;
241
245
  return <p {...domProps}>{children}</p>;
242
246
  },
243
- a: ({ href, children, ...props }) => {
244
- const external = Boolean(href && /^(https?:)?\/\//iu.test(href));
247
+ a: ({ href: written, children, ...props }) => {
248
+ const { href, external } = articleLink(written, sameSite);
245
249
  const domProps = { ...props };
246
250
  Reflect.deleteProperty(domProps, "node");
247
251
  return (
@@ -65,6 +65,8 @@ declare module "@garden/site-config" {
65
65
  export const searchIndexUrl: string;
66
66
  /** The full-text index file and the hash its contents are cached under. */
67
67
  export const searchFull: { url: string; hash: string };
68
+ /** The site address (site.config.yaml `url`, else SITE_URL) and base path, for spotting links back to the site. */
69
+ export const siteLinks: { address: string; basePath: string };
68
70
  export const siteConfig: {
69
71
  name: string;
70
72
  tagline: string;
@@ -74,6 +76,7 @@ declare module "@garden/site-config" {
74
76
  pages: Record<string, Record<string, unknown>>;
75
77
  footer: string;
76
78
  linkPreview?: { worker?: string };
79
+ url?: string;
77
80
  };
78
81
  export const githubUrl: string;
79
82
  }
package/app/globals.css CHANGED
@@ -2061,15 +2061,32 @@ button {
2061
2061
  text-decoration-thickness: 2px;
2062
2062
  }
2063
2063
 
2064
- /* A per-line border stays continuous across inline code and descenders. */
2065
- .markdown-body u {
2064
+ /* Highlighter for key claims, as in the A2 demo: a soft stroke under the lower part of the line. */
2065
+ .markdown-body u,
2066
+ .markdown-body mark,
2067
+ .markdown-body .hl {
2068
+ color: var(--ink);
2069
+ font-weight: 500;
2066
2070
  text-decoration: none;
2067
- border-bottom: 1px solid currentColor;
2068
- padding-bottom: 0.1em;
2071
+ border-bottom: 0;
2072
+ background: linear-gradient(to bottom, transparent 58%, var(--hl) 58%, var(--hl) 92%, transparent 92%);
2073
+ padding: 0 0.08em;
2069
2074
  -webkit-box-decoration-break: clone;
2070
2075
  box-decoration-break: clone;
2071
2076
  }
2072
2077
 
2078
+ .markdown-body u strong,
2079
+ .markdown-body u u,
2080
+ .markdown-body mark strong,
2081
+ .markdown-body mark u,
2082
+ .markdown-body .hl strong,
2083
+ .markdown-body .hl u {
2084
+ font-weight: 600;
2085
+ text-decoration: none;
2086
+ background: none;
2087
+ padding: 0;
2088
+ }
2089
+
2073
2090
  .markdown-body strong {
2074
2091
  color: var(--ink);
2075
2092
  }
@@ -18,10 +18,10 @@ const LEGACY_KIND = {
18
18
 
19
19
  const ALERT_ICON = {
20
20
  note: '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2v4"/><path d="M8 4.8v.1" stroke-width="2"/></svg>',
21
- tip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 12.5h4M6.5 14.5h3M8 1.75a4.5 4.5 0 0 0-2.6 8.2c.4.3.6.7.6 1.2v.35h4v-.35c0-.5.2-.9.6-1.2A4.5 4.5 0 0 0 8 1.75Z"/></svg>',
22
- important: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 3.25a1 1 0 0 1 1-1h9.5a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7l-3 2.5v-2.5h-.75a1 1 0 0 1-1-1Z"/><path d="M8 4.75v3"/><path d="M8 9.4v.1" stroke-width="2"/></svg>',
23
- warning: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 1.75 13.25h12.5Z"/><path d="M8 6.5v3.25"/><path d="M8 11.6v.1" stroke-width="2"/></svg>',
24
- caution: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.3 1.75h5.4l3.55 3.55v5.4l-3.55 3.55H5.3l-3.55-3.55V5.3Z"/><path d="M8 4.75v3.75"/><path d="M8 10.9v.1" stroke-width="2"/></svg>',
21
+ tip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.75a4.25 4.25 0 0 0-2.5 7.7c.4.3.6.7.6 1.2v.6h3.8v-.6c0-.5.2-.9.6-1.2A4.25 4.25 0 0 0 8 1.75z"/><path d="M6.2 13.6h3.6"/></svg>',
22
+ important: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 3.25a1.5 1.5 0 0 1 1.5-1.5h8.5a1.5 1.5 0 0 1 1.5 1.5v6.5a1.5 1.5 0 0 1-1.5 1.5H7.5l-3 2.75v-2.75h-.75a1.5 1.5 0 0 1-1.5-1.5z"/><path d="M8 4.5v3"/><path d="M8 9.4v.1" stroke-width="2"/></svg>',
23
+ warning: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.13 2.4a1 1 0 0 1 1.74 0l5.6 9.9a1 1 0 0 1-.87 1.5H2.4a1 1 0 0 1-.87-1.5z"/><path d="M8 6.3v3.1"/><path d="M8 11.4v.1" stroke-width="2"/></svg>',
24
+ caution: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.3 1.75h5.4l3.55 3.55v5.4l-3.55 3.55H5.3L1.75 10.7V5.3z"/><path d="M8 4.8v3.6"/><path d="M8 10.6v.1" stroke-width="2"/></svg>',
25
25
  };
26
26
 
27
27
  function meaningfulChildren(node) {
@@ -32,7 +32,7 @@ const meaningful = (children) =>
32
32
  children.filter((child) => !(child.type === "text" && !child.value.trim()) && child.type !== "break");
33
33
 
34
34
  /** Take the markers out of one parent's inline children; return the marked links. */
35
- function takeMarkers(parent, source) {
35
+ function takeMarkers(parent, source, sameSite) {
36
36
  const marked = [];
37
37
  const children = parent.children ?? [];
38
38
  for (let index = 0; index < children.length - 1; index += 1) {
@@ -41,13 +41,14 @@ function takeMarkers(parent, source) {
41
41
  if (link.type !== "link" || next.type !== "text" || !next.value.startsWith(MARKER)) continue;
42
42
  if (!isHttp(link.url) || !writtenAsLink(link, source)) continue;
43
43
  next.value = next.value.slice(MARKER.length);
44
- marked.push(link);
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
46
  }
46
47
  parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
47
48
  return marked;
48
49
  }
49
50
 
50
- function walk(parent, source) {
51
+ function walk(parent, source, sameSite) {
51
52
  const children = parent.children ?? [];
52
53
  for (let index = 0; index < children.length; index += 1) {
53
54
  const node = children[index];
@@ -55,14 +56,14 @@ function walk(parent, source) {
55
56
  if (node.type !== "paragraph") {
56
57
  if (node.children) {
57
58
  // Headings, table cells and the like: drop the marker, no card.
58
- if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source);
59
- walk(node, source);
59
+ if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source, sameSite);
60
+ walk(node, source, sameSite);
60
61
  }
61
62
  continue;
62
63
  }
63
64
  // Links nested in emphasis count too.
64
- const marked = takeMarkers(node, source);
65
- for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source));
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));
66
67
  if (!marked.length) continue;
67
68
  const rest = meaningful(node.children);
68
69
  if (marked.length === 1 && rest.length === 1 && rest[0] === marked[0]) {
@@ -75,9 +76,10 @@ function walk(parent, source) {
75
76
  }
76
77
  }
77
78
 
78
- export function remarkLinkCard() {
79
+ export function remarkLinkCard(options = {}) {
80
+ const sameSite = typeof options?.sameSite === "function" ? options.sameSite : () => null;
79
81
  return (tree, file) => {
80
82
  const source = typeof file?.value === "string" ? file.value : String(file?.value ?? "");
81
- walk(tree, source);
83
+ walk(tree, source, sameSite);
82
84
  };
83
85
  }
package/app/styles/a2.css CHANGED
@@ -466,15 +466,15 @@ html[data-theme="dark"] .markdown-body img.fav {
466
466
  }
467
467
 
468
468
  .markdown-body.prose {
469
- --al-note: #30302e;
470
- --al-note-bg: #f0eee6;
471
- --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);
472
472
  --al-tip: #2d6a3e;
473
473
  --al-tip-bg: #edf5ef;
474
474
  --al-tip-line: rgba(45, 106, 62, 0.22);
475
- --al-important: #30302e;
476
- --al-important-bg: #f0eee6;
477
- --al-important-line: rgba(20, 20, 19, 0.14);
475
+ --al-important: #6a3fb5;
476
+ --al-important-bg: #f3effa;
477
+ --al-important-line: rgba(106, 63, 181, 0.22);
478
478
  --al-warning: #8a5300;
479
479
  --al-warning-bg: #fbf3e5;
480
480
  --al-warning-line: rgba(176, 108, 0, 0.26);
@@ -487,15 +487,15 @@ html[data-theme="dark"] .markdown-body img.fav {
487
487
  :root[data-theme="dark"] .prose,
488
488
  html[data-theme="dark"] .markdown-body.prose,
489
489
  html[data-theme="dark"] .prose {
490
- --al-note: #eeece7;
491
- --al-note-bg: rgba(238, 236, 231, 0.06);
492
- --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);
493
493
  --al-tip: #8fcb9f;
494
494
  --al-tip-bg: rgba(143, 203, 159, 0.08);
495
495
  --al-tip-line: rgba(143, 203, 159, 0.26);
496
- --al-important: #eeece7;
497
- --al-important-bg: #30302e;
498
- --al-important-line: rgba(238, 236, 231, 0.14);
496
+ --al-important: #bfa5f0;
497
+ --al-important-bg: rgba(191, 165, 240, 0.09);
498
+ --al-important-line: rgba(191, 165, 240, 0.28);
499
499
  --al-warning: #e6b566;
500
500
  --al-warning-bg: rgba(230, 181, 102, 0.08);
501
501
  --al-warning-line: rgba(230, 181, 102, 0.28);
@@ -535,17 +535,10 @@ html[data-theme="dark"] .prose {
535
535
  --al-line: var(--al-tip-line);
536
536
  }
537
537
 
538
- /* IMPORTANT reads in ink: a dark bar on the left and a bold title. */
539
538
  .markdown-body .markdown-alert-important {
540
539
  --al: var(--al-important);
541
540
  --al-bg: var(--al-important-bg);
542
541
  --al-line: var(--al-important-line);
543
- border-left: 3px solid var(--al-important);
544
- padding-left: 18px;
545
- }
546
-
547
- .markdown-body .markdown-alert-important .markdown-alert-title {
548
- font-weight: 700;
549
542
  }
550
543
 
551
544
  .markdown-body .markdown-alert-warning {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -10,6 +10,7 @@ import { countDemoWords } from "../ui/garden/word-count.mjs";
10
10
  import { referenceDomain } from "../ui/garden/references.mjs";
11
11
  import { linkAssetsForRender } from "./link-assets.mjs";
12
12
  import { cardUrls } from "../ui/garden/markdown-links.mjs";
13
+ import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
13
14
  import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
14
15
  import { coverImageSet, loadSharp } from "./cover-images.mjs";
15
16
 
@@ -39,6 +40,9 @@ const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "ht
39
40
  const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
40
41
  ? `${siteUrl}${basePath}`
41
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);
42
46
  const siteName = process.env.SITE_NAME ?? siteConfig.name;
43
47
  const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
44
48
 
@@ -630,6 +634,8 @@ export const githubUrl = ${JSON.stringify(`https://github.com/${siteConfig.autho
630
634
  export const searchIndexUrl = ${JSON.stringify(`${basePath}/search-index.json?v=${contentHash}`)};
631
635
  // Every body as plain text, fetched in the background after load; the name changes with the text.
632
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 })};
633
639
  `,
634
640
  );
635
641
  fs.mkdirSync(path.join(root, "public"), { recursive: true });
@@ -701,7 +707,7 @@ const sitemapEntries = [
701
707
  // Card URLs in published entries: the only pages the link-card Worker may read.
702
708
  fs.writeFileSync(
703
709
  path.join(publicRoot, "link-allowlist.json"),
704
- `${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)))].sort() }, null, 2)}\n`,
710
+ `${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)).filter((url) => !sameSite(url)))].sort() }, null, 2)}\n`,
705
711
  );
706
712
  fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
707
713
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
@@ -17,7 +17,9 @@ import path from "node:path";
17
17
  import { pathToFileURL } from "node:url";
18
18
  import matter from "gray-matter";
19
19
  import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, linkHost } from "./link-meta-core.mjs";
20
+ import { parse as parseYaml } from "yaml";
20
21
  import { markdownLinks } from "../ui/garden/markdown-links.mjs";
22
+ import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
21
23
 
22
24
  export const CACHE_FILE = "link-assets.json";
23
25
  export const ASSET_DIR = path.join("public", "link-assets");
@@ -49,15 +51,26 @@ function markdownFiles(directory) {
49
51
  });
50
52
  }
51
53
 
54
+ function readSiteConfig(siteRoot) {
55
+ try {
56
+ return parseYaml(fs.readFileSync(path.join(siteRoot, "site.config.yaml"), "utf8")) ?? {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
52
62
  /** External links across every post: the sites (for favicons) and the URLs. */
53
- export function collectSiteLinks(siteRoot) {
63
+ export function collectSiteLinks(siteRoot, env = process.env) {
64
+ const config = readSiteConfig(siteRoot);
65
+ // Links back to this site are internal: no favicon, no card.
66
+ const sameSite = sameSiteMatcher(configuredSiteAddress(config, env), env.NEXT_PUBLIC_BASE_PATH ?? "");
54
67
  const hosts = new Map();
55
68
  const cards = new Set();
56
69
  const urls = new Set();
57
70
  for (const file of markdownFiles(path.join(siteRoot, "content"))) {
58
71
  const { content } = matter(fs.readFileSync(file, "utf8"));
59
72
  for (const { url, card } of markdownLinks(content)) {
60
- if (!isPublicUrl(url)) continue;
73
+ if (!isPublicUrl(url) || sameSite(url)) continue;
61
74
  urls.add(url);
62
75
  if (card) cards.add(url);
63
76
  const host = linkHost(url);
@@ -2,6 +2,8 @@ name: Garden
2
2
  tagline: 写作 · 记录 · 保持好奇
3
3
  description: 文章、随想和专栏。
4
4
  locale: zh_CN
5
+ # 站点的公开地址。文章里指向这个地址的完整网址会按站内链接显示。
6
+ # url: https://blog.example.com
5
7
 
6
8
  author:
7
9
  name: Your Name
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Links to the site's own address. A post may write a full URL such as
3
+ * `https://example.com/blog/post/`; it is still an internal link.
4
+ *
5
+ * The site address comes from `url` in site.config.yaml, falling back to the
6
+ * SITE_URL the build already uses. Both http and https count, and so do the
7
+ * host with and without `www.`. Any other subdomain is a different site.
8
+ */
9
+
10
+ /** The configured site address, or "" when none is set. */
11
+ export function configuredSiteAddress(config = {}, env = {}) {
12
+ const value = config?.url ?? config?.siteUrl ?? env.SITE_URL ?? env.NEXT_PUBLIC_SITE_URL ?? "";
13
+ return String(value).trim();
14
+ }
15
+
16
+ const hostKey = (hostname) => hostname.toLowerCase().replace(/\.$/u, "").replace(/^www\./u, "");
17
+ const normalizeBase = (basePath) => {
18
+ const trimmed = String(basePath ?? "").trim().replace(/\/+$/u, "");
19
+ return !trimmed ? "" : trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
20
+ };
21
+
22
+ /**
23
+ * Returns `href => path | null`. The path is root-relative and keeps the base
24
+ * path, query and hash; null means the link points somewhere else.
25
+ */
26
+ export function sameSiteMatcher(siteAddress, basePath = "") {
27
+ let site;
28
+ try {
29
+ site = siteAddress ? new URL(siteAddress) : null;
30
+ } catch {
31
+ site = null;
32
+ }
33
+ if (!site || !/^https?:$/u.test(site.protocol)) return () => null;
34
+ const key = hostKey(site.hostname);
35
+ const port = site.port;
36
+ // A base path written into the site address counts the same as NEXT_PUBLIC_BASE_PATH.
37
+ const base = normalizeBase(basePath) || normalizeBase(site.pathname);
38
+ return (href) => {
39
+ if (typeof href !== "string" || !/^(https?:)?\/\//iu.test(href.trim())) return null;
40
+ let url;
41
+ try {
42
+ url = new URL(href.trim(), `${site.protocol}//${site.host}/`);
43
+ } catch {
44
+ return null;
45
+ }
46
+ if (!/^https?:$/u.test(url.protocol) || url.username || url.password) return null;
47
+ if (hostKey(url.hostname) !== key || url.port !== port) return null;
48
+ const pathname = url.pathname || "/";
49
+ if (base && pathname !== base && !pathname.startsWith(`${base}/`)) return null;
50
+ return `${pathname}${url.search}${url.hash}`;
51
+ };
52
+ }
53
+
54
+ /**
55
+ * How an article link renders: a link back to the site becomes its
56
+ * root-relative path and opens in place; other full URLs are external.
57
+ */
58
+ export function articleLink(href, sameSite) {
59
+ if (!href) return { href, external: false };
60
+ const local = sameSite(href);
61
+ if (local !== null) return { href: local, external: false };
62
+ return { href, external: /^(https?:)?\/\//iu.test(href) };
63
+ }