hd-garden 0.5.13 → 0.5.15

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.
Files changed (54) hide show
  1. package/app/a2/page.tsx +2 -1
  2. package/app/about/page.tsx +2 -1
  3. package/app/columns/[column]/[...path]/page.tsx +2 -1
  4. package/app/components/ArticleTags.tsx +6 -3
  5. package/app/components/BlogArchive.tsx +3 -2
  6. package/app/components/ContentEmptyState.tsx +2 -1
  7. package/app/components/MarkdownArticle.tsx +40 -3
  8. package/app/components/SiteHeader.tsx +18 -15
  9. package/app/components/TagRedirect.tsx +4 -3
  10. package/app/components/ThoughtCard.tsx +3 -2
  11. package/app/components/garden/FilePreview.tsx +176 -0
  12. package/app/components/garden/GardenBlock.tsx +4 -0
  13. package/app/components/garden/GardenFile.tsx +69 -0
  14. package/app/components/garden/file-icons.tsx +49 -0
  15. package/app/garden-modules.d.ts +1 -1
  16. package/app/layout.tsx +3 -0
  17. package/app/lib/content.ts +5 -3
  18. package/app/lib/markdown-plugins.ts +2 -11
  19. package/app/lib/site-href.ts +9 -0
  20. package/app/lib/tag-filter.ts +8 -0
  21. package/app/page.tsx +5 -4
  22. package/app/rss.xml/route.ts +6 -3
  23. package/app/sitemap.ts +4 -3
  24. package/app/styles/a2.css +72 -0
  25. package/app/styles/shell.css +21 -2
  26. package/app/tags/page.tsx +3 -2
  27. package/app/thoughts/[slug]/page.tsx +4 -3
  28. package/bin/garden.mjs +10 -2
  29. package/next.config.ts +3 -1
  30. package/package.json +3 -1
  31. package/scripts/build-content.mjs +110 -10
  32. package/scripts/check-post.mjs +45 -0
  33. package/scripts/file-preview.mjs +138 -0
  34. package/scripts/file-sizes.mjs +146 -0
  35. package/scripts/prepare-pages-output.mjs +7 -1
  36. package/scripts/redirects.mjs +80 -55
  37. package/templates/garden-blocks.md +11 -0
  38. package/templates/garden-snippets.plist +9 -0
  39. package/ui/core/transform.mjs +18 -0
  40. package/ui/garden/catalog.mjs +15 -1
  41. package/ui/garden/check.mjs +12 -0
  42. package/ui/garden/file-block.mjs +232 -0
  43. package/ui/garden/file-paths.mjs +65 -0
  44. package/ui/garden/hash-jump.mjs +37 -0
  45. package/ui/garden/internal-links.mjs +113 -0
  46. package/ui/garden/markdown-syntax.mjs +12 -0
  47. package/ui/garden/parse.mjs +1 -1
  48. package/ui/garden/same-site.mjs +27 -0
  49. package/ui/garden/site-href.mjs +62 -0
  50. package/ui/garden/site-nav.mjs +25 -0
  51. package/ui/garden/snippets.mjs +2 -2
  52. package/ui/garden/word-count.mjs +5 -0
  53. package/ui/preview/dist/garden-ui.esm.js +33 -33
  54. package/ui/preview/dist/garden-ui.iife.js +32 -32
@@ -15,24 +15,26 @@ import remarkParse from "remark-parse";
15
15
  import { unified } from "unified";
16
16
  import { visit } from "unist-util-visit";
17
17
  import { splitHeadingNumber } from "./heading-number";
18
+ import { siteHref } from "./site-href";
18
19
 
19
20
  export { columns, posts, thoughts };
20
21
  export type { ContentEntry, MediaItem };
21
22
 
22
- export const postHref = (post: Pick<ContentEntry, "path">) => `/blog/${post.path}`;
23
+ export const postHref = (post: Pick<ContentEntry, "path">) => siteHref(`/blog/${post.path}`);
23
24
  export const columnEntryPath = (entry: Pick<ContentEntry, "column" | "path">) => {
24
25
  const prefix = entry.column ? `${entry.column}/` : "";
25
26
  return prefix && entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path;
26
27
  };
27
28
  export const columnHref = (entry: Pick<ContentEntry, "column" | "path">) =>
28
- `/columns/${entry.column}/${columnEntryPath(entry)}`;
29
+ siteHref(`/columns/${entry.column}/${columnEntryPath(entry)}`);
29
30
 
30
31
  /** The column a post belongs to (the first one, if it is listed in several). */
31
32
  export const columnOfPost = (post: Pick<ContentEntry, "path">) => {
32
33
  const entry = columns.find((item) => item.path === post.path && item.column);
33
34
  return entry?.column ? { slug: entry.column, title: entry.columnTitle ?? entry.column } : undefined;
34
35
  };
35
- export const columnIndexHref = (slug: string) => `/columns/#${slug}`;
36
+ export const columnIndexHref = (slug: string) => siteHref(`/columns/#${slug}`);
37
+ export const thoughtHref = (slug: string) => siteHref(`/thoughts/${slug}`);
36
38
 
37
39
  export const formatDate = (value: string, withTime = false) => {
38
40
  if (!withTime) return demoDateLabel(value);
@@ -1,15 +1,6 @@
1
- import remarkGfm from "remark-gfm";
2
- import remarkCjkFriendly from "remark-cjk-friendly";
3
- import remarkBreaks from "remark-breaks";
4
- import remarkMath from "remark-math";
5
1
  import type { PluggableList } from "unified";
2
+ import { markdownSyntaxPlugins } from "../../ui/garden/markdown-syntax.mjs";
6
3
  import { remarkGarden } from "./remark-garden.mjs";
7
4
 
8
5
  // Keep article rendering and heading extraction on the same Markdown syntax.
9
- export const markdownPlugins: PluggableList = [
10
- remarkGfm,
11
- remarkCjkFriendly,
12
- remarkBreaks,
13
- remarkMath,
14
- remarkGarden,
15
- ];
6
+ export const markdownPlugins: PluggableList = [...markdownSyntaxPlugins, remarkGarden];
@@ -0,0 +1,9 @@
1
+ import { siteLinks } from "@garden/site-config";
2
+ import { withTrailingSlash } from "../../ui/garden/site-href.mjs";
3
+
4
+ /**
5
+ * Every address inside the site goes through here. Page paths follow Next's
6
+ * trailingSlash (always on in a static export, TRAILING_SLASH=1 on a server);
7
+ * ?query and #hash come after the path.
8
+ */
9
+ export const siteHref = (href: string): string => withTrailingSlash(href, siteLinks?.trailingSlash === true);
@@ -145,6 +145,14 @@ export function tagFilterHref(selected: readonly string[], pathname = "/blog") {
145
145
  return query ? `${pathname}?${query}` : pathname;
146
146
  }
147
147
 
148
+ /**
149
+ * Where a tag link points (tags on an article, the /tags page): the list page at its exported address
150
+ * (`/blog/`, with the slash, so the static host does not answer /blog?t=… with
151
+ * a 301 first), filtered to that one tag. The tag is URL-encoded.
152
+ */
153
+ export const ARTICLE_TAG_LIST = "/blog/";
154
+ export const articleTagHref = (tag: string) => tagFilterHref([tag], ARTICLE_TAG_LIST);
155
+
148
156
  /** 「共 N 篇」, or 「2 个标签,共 5 篇」 with a selection. */
149
157
  export function tagSummary(selectedCount: number, resultCount: number) {
150
158
  return selectedCount ? `${selectedCount} 个标签,共 ${resultCount} 篇` : `共 ${resultCount} 篇`;
package/app/page.tsx CHANGED
@@ -3,6 +3,7 @@ import { CoverImage } from "./components/CoverImage";
3
3
  import { homeListDate } from "./lib/a2-meta";
4
4
  import { columns, formatDate, postHref, posts } from "./lib/content";
5
5
  import { githubUrl, siteConfig } from "./site.config";
6
+ import { siteHref } from "./lib/site-href";
6
7
 
7
8
  export const metadata = {
8
9
  title: { absolute: siteConfig.name },
@@ -40,7 +41,7 @@ export default function Home() {
40
41
  <h1>{siteConfig.pages.home.title}</h1>
41
42
  <p>{siteConfig.pages.home.subtitle || siteConfig.description}</p>
42
43
  <div className="links">
43
- <Link href="/about">关于我</Link>
44
+ <Link href={siteHref("/about")}>关于我</Link>
44
45
  <a href={githubUrl} rel="me">GitHub</a>
45
46
  <a href="/rss.xml">RSS 订阅</a>
46
47
  </div>
@@ -71,7 +72,7 @@ export default function Home() {
71
72
  <section className="sh-section" aria-labelledby="h-recent">
72
73
  <div className="sh-sec-head">
73
74
  <h2 id="h-recent">最近更新</h2>
74
- <Link className="sh-more" href="/blog">全部 {posts.length} 篇</Link>
75
+ <Link className="sh-more" href={siteHref("/blog")}>全部 {posts.length} 篇</Link>
75
76
  </div>
76
77
  <ol className="sh-rows">
77
78
  {recent.slice(0, 6).map((post) => (
@@ -102,7 +103,7 @@ export default function Home() {
102
103
  <section className="sh-section" aria-labelledby="h-cols">
103
104
  <div className="sh-sec-head">
104
105
  <h2 id="h-cols">专栏</h2>
105
- <Link className="sh-more" href="/columns">全部专栏</Link>
106
+ <Link className="sh-more" href={siteHref("/columns")}>全部专栏</Link>
106
107
  </div>
107
108
  <div className="sh-cols">
108
109
  {series.map(([slug, entries]) => {
@@ -116,7 +117,7 @@ export default function Home() {
116
117
  ) : null}
117
118
  </div>
118
119
  <div className="body">
119
- <h3><Link href={`/columns/#${slug}`}>{first.columnTitle}</Link></h3>
120
+ <h3><Link href={siteHref(`/columns/#${slug}`)}>{first.columnTitle}</Link></h3>
120
121
  <p className="count">{entries.length} 篇,{first.columnStatus ?? "持续更新"}</p>
121
122
  <p>{first.columnDescription ?? first.description}</p>
122
123
  </div>
@@ -1,9 +1,10 @@
1
1
  import { marked } from "marked";
2
- import { columnHref, columns, postHref, posts } from "../lib/content";
2
+ import { columnHref, columns, postHref, posts, thoughtHref } 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
6
  import { stripCardMarkers } from "../../ui/garden/markdown-links.mjs";
7
+ import { withTrailingSlash } from "../../ui/garden/site-href.mjs";
7
8
 
8
9
  const escapeXml = (value: string) =>
9
10
  value
@@ -15,6 +16,8 @@ const escapeXml = (value: string) =>
15
16
 
16
17
  const cdata = (value: string) => value.replaceAll("]]>", "]]]]><![CDATA[>");
17
18
 
19
+ // The link follows trailingSlash; the guid keeps this route's 0.5.14 form (no trailing slash),
20
+ // because readers tell items apart by it.
18
21
  export async function GET() {
19
22
  const origin = configuredSiteUrl();
20
23
  const thoughts = getPublishedThoughts();
@@ -27,7 +30,7 @@ export async function GET() {
27
30
  ...thoughts.map((thought) => ({
28
31
  ...thought,
29
32
  description: stripCardMarkers(thought.content).replace(/\s+/gu, " ").slice(0, 180),
30
- path: `/thoughts/${thought.slug}`,
33
+ path: thoughtHref(thought.slug),
31
34
  })),
32
35
  ].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
33
36
 
@@ -45,7 +48,7 @@ export async function GET() {
45
48
  <item>
46
49
  <title>${escapeXml(entry.title)}</title>
47
50
  <link>${origin}${entry.path}</link>
48
- <guid isPermaLink="true">${origin}${entry.path}</guid>
51
+ <guid isPermaLink="true">${origin}${withTrailingSlash(entry.path, false)}</guid>
49
52
  <pubDate>${new Date(entry.date).toUTCString()}</pubDate>
50
53
  <description>${escapeXml(entry.description ?? "")}</description>
51
54
  ${(entry.tags ?? [])
package/app/sitemap.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { MetadataRoute } from "next";
2
- import { columnHref, columns, postHref, posts } from "./lib/content";
2
+ import { columnHref, columns, postHref, posts, thoughtHref } from "./lib/content";
3
3
  import { absoluteUrl, getSiteUrl } from "./lib/site";
4
4
  import { getPublishedThoughts } from "./lib/public-thoughts";
5
+ import { siteHref } from "./lib/site-href";
5
6
 
6
7
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
7
8
  const baseUrl = await getSiteUrl();
@@ -14,7 +15,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
14
15
  { path: "/about", priority: 0.5, changeFrequency: "monthly" as const },
15
16
  { path: "/garden", priority: 0.4, changeFrequency: "monthly" as const },
16
17
  ].map((route) => ({
17
- url: absoluteUrl(route.path, baseUrl),
18
+ url: absoluteUrl(siteHref(route.path), baseUrl),
18
19
  changeFrequency: route.changeFrequency,
19
20
  priority: route.priority,
20
21
  }));
@@ -37,7 +38,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
37
38
  priority: 0.75,
38
39
  })),
39
40
  ...thoughts.map((thought) => ({
40
- url: absoluteUrl(`/thoughts/${thought.slug}`, baseUrl),
41
+ url: absoluteUrl(thoughtHref(thought.slug), baseUrl),
41
42
  lastModified: new Date(thought.updated ?? thought.date),
42
43
  changeFrequency: "monthly" as const,
43
44
  priority: 0.6,
package/app/styles/a2.css CHANGED
@@ -1567,3 +1567,75 @@ html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
1567
1567
  @media (max-width: 640px) {
1568
1568
  .g-range { grid-template-columns: 84px minmax(0, 1fr) 54px; gap: 8px; }
1569
1569
  }
1570
+
1571
+ /* ---- garden file: a download card, a group of rows, a preview drawer ---- */
1572
+ .gf { margin: 24px 0; border: 1px solid var(--rule); border-radius: 12px; background: var(--card); font-family: var(--sans); }
1573
+ .gf-title { padding: 14px 16px 0; font-size: 14px; font-weight: 600; line-height: 1.4; color: var(--muted); }
1574
+ .gf-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
1575
+ .gf-row + .gf-row { border-top: 1px solid var(--rule); }
1576
+ .gf-ico { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 8px; background: var(--chip); color: var(--soft); }
1577
+ .gf-i { flex: none; width: 18px; height: 18px; }
1578
+ .gf-main { flex: 1; min-width: 0; }
1579
+ .gf-name { overflow: hidden; font-size: 15px; font-weight: 600; line-height: 1.45; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; }
1580
+ .gf-desc { margin-top: 2px; font-size: 14px; line-height: 1.55; color: var(--soft); }
1581
+ .gf-meta { margin-top: 2px; font-size: 13px; line-height: 1.4; color: var(--muted); font-variant-numeric: tabular-nums; }
1582
+ .gf-act { display: flex; flex: none; gap: 6px; }
1583
+ .gf-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; padding: 0 12px; border: 0; border-radius: 999px; background: transparent; color: var(--soft); font: 500 14px/1 var(--sans); white-space: nowrap; cursor: pointer; transition: background-color .15s ease, color .15s ease; }
1584
+ .markdown-body a.gf-btn, a.gf-btn { color: var(--soft); text-decoration: none; }
1585
+ .gf-btn:hover, .markdown-body a.gf-btn:hover { background: var(--tint); color: var(--ink); text-decoration: none; }
1586
+ .gf-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
1587
+ .gf-btn.icon { width: 34px; padding: 0; }
1588
+ .gf-btn.pri, .markdown-body a.gf-btn.pri { background: var(--ink); color: var(--paper); }
1589
+ .gf-btn.pri:hover, .markdown-body a.gf-btn.pri:hover { background: var(--ink-soft); color: var(--paper); }
1590
+
1591
+ /* The drawer: right side on wide screens, the whole screen on phones. */
1592
+ .gf-layer { position: fixed; inset: 0; z-index: 95; }
1593
+ .gf-scrim { position: absolute; inset: 0; background: rgb(20 20 19 / 22%); animation: gf-fade .16s ease-out; }
1594
+ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
1595
+ .gf-drawer { position: absolute; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(600px, 46vw); border-left: 1px solid var(--rule); background: var(--paper); box-shadow: -12px 0 32px rgb(0 0 0 / 8%); font-family: var(--sans); animation: gf-slide .2s ease-out; }
1596
+ .gf-dh { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px 12px 12px 20px; border-bottom: 1px solid var(--rule); }
1597
+ .gf-dh .gf-act { gap: 2px; }
1598
+ .gf-db { flex: 1; min-height: 0; overflow: auto; padding: 28px 32px 40px; font-size: 15px; line-height: 1.75; overscroll-behavior: contain; }
1599
+ .gf-db-frame { padding: 0; overflow: hidden; background: var(--card); }
1600
+ .gf-frame { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
1601
+ .gf-drawer[data-preview="pdf"] .gf-frame { background: var(--paper-deep); }
1602
+ .gf-db .gf-doc { max-width: none; margin: 0; font-size: 15px; line-height: 1.75; }
1603
+ .gf-db .gf-doc > :first-child { margin-top: 0; }
1604
+ @keyframes gf-fade { from { opacity: 0; } }
1605
+ @keyframes gf-slide { from { transform: translateX(24px); opacity: 0; } }
1606
+ @media (prefers-reduced-motion: reduce) {
1607
+ .gf-scrim, .gf-drawer { animation: none; }
1608
+ .gf-btn { transition: none; }
1609
+ }
1610
+
1611
+ @media (max-width: 600px) {
1612
+ .gf-row { flex-wrap: wrap; align-items: flex-start; gap: 12px; padding: 14px; }
1613
+ .gf-main { flex: 1 1 calc(100% - 50px); }
1614
+ .gf-act { width: 100%; padding-left: 48px; }
1615
+ .gf-btn { height: 44px; padding: 0 14px; }
1616
+ .gf-btn.icon { width: 44px; padding: 0; }
1617
+ .gf.group .gf-row { flex-wrap: nowrap; align-items: center; }
1618
+ .gf.group .gf-main { flex: 1 1 auto; }
1619
+ .gf.group .gf-act { width: auto; gap: 0; padding: 0; }
1620
+ .gf.group .gf-btn { width: 44px; padding: 0; }
1621
+ .gf.group .gf-lb { display: none; }
1622
+ .gf-drawer { width: 100%; border: 0; box-shadow: none; }
1623
+ .gf-dh { min-height: 56px; padding: 6px 6px 6px 16px; }
1624
+ .gf-dh .gf-act { width: auto; padding: 0; gap: 0; }
1625
+ .gf-db { padding: 20px 20px 40px; }
1626
+ .gf-db-frame { padding: 0; }
1627
+ }
1628
+
1629
+ /* While the prerendered Markdown loads, or when it cannot. (Without scripts 预览 is a plain link to it.) */
1630
+ .gf-state { display: grid; justify-items: start; gap: 12px; padding: 8px 0; font-size: 15px; line-height: 1.6; color: var(--muted); }
1631
+ .gf-state p { margin: 0; color: var(--soft); }
1632
+ .gf-state-act { display: flex; flex-wrap: wrap; gap: 8px; }
1633
+ .gf-state-act .gf-btn { border: 1px solid var(--rule); }
1634
+ /* A previewed Markdown file reads as a document: its own title, a smaller scale than the post. */
1635
+ .gf-db .gf-doc h1 { margin: 0 0 12px; font-size: 22px; font-weight: 600; line-height: 1.4; color: var(--ink); }
1636
+ .gf-db .gf-doc h2 { margin: 28px 0 10px; font-size: 19px; line-height: 1.4; }
1637
+ .gf-db .gf-doc h3 { margin: 24px 0 8px; font-size: 17px; line-height: 1.4; }
1638
+ .gf-db .gf-doc h4 { margin: 20px 0 6px; font-size: 15px; line-height: 1.5; }
1639
+ .gf-db .gf-doc p, .gf-db .gf-doc li { font-size: 15px; line-height: 1.75; }
1640
+ .gf-db .gf-doc p { margin: 0 0 14px; }
1641
+ .gf-db .gf-doc ul, .gf-db .gf-doc ol { margin: 0 0 14px; }
@@ -65,10 +65,11 @@ html[data-mod="other"] .sh-search .kbd-other{display:inline}
65
65
  .sh-icon{display:inline-grid;place-items:center;width:40px;height:40px;border:0;background:transparent;color:var(--soft);border-radius:var(--r);cursor:pointer;transition:background-color .15s ease,color .15s ease}
66
66
  @media (hover:hover){.sh-icon:hover{background:var(--tint);color:var(--ink)}}
67
67
  .sh-menu-btn,.sh-search-m{display:none}
68
+ .sh-menu-nojs,.sh-menu-nojs-close{display:none}
69
+ /* Fragment targets for the no-JS menu: fixed, so jumping to them never scrolls the page. */
70
+ .sh-menu-target{position:fixed;top:0;left:0;width:0;height:0;overflow:hidden;pointer-events:none}
68
71
  .i-sun,html[data-theme="dark"] .i-moon{display:none}
69
72
  html[data-theme="dark"] .i-sun{display:inline}
70
- /* No JavaScript: the theme cannot be switched, the page follows the system (build/dark-fallback-postcss.mjs). */
71
- @media (scripting:none){.sh-theme,.sh-menu-theme{display:none}}
72
73
  .sh-menu{display:none}
73
74
 
74
75
  /* ---------- page frame ---------- */
@@ -304,6 +305,24 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
304
305
  .sh-pal-foot{display:none}
305
306
  .sh-pal-input{font-size:17px;height:56px}
306
307
  }
308
+ /* No JavaScript: the theme cannot be switched, the page follows the system (build/dark-fallback-postcss.mjs).
309
+ Keep this after every rule that shows the toggles (the 820px block above sets .sh-menu-theme to flex);
310
+ tests/header-nojs.test.mjs checks the order in the built CSS. */
311
+ @media (scripting:none){.sh-theme,.sh-menu-theme{display:none}}
312
+ /* Search needs JavaScript too: without it the desktop 「搜索」 button would do nothing. */
313
+ @media (scripting:none){.sh-search{display:none}}
314
+ /* No JavaScript: the ☰ button cannot open the menu, so a ☰ link in the same place opens it by targeting
315
+ #sh-menu-on, and ✕ closes it by targeting #sh-menu-off. Both are fixed anchors outside the sticky header,
316
+ as the TOC drawer's are, so neither moves the page. With JavaScript none of this applies. */
317
+ @media (max-width:820px) and (scripting:none){
318
+ .sh-menu-btn{display:none}
319
+ /* Search needs JavaScript (the index loads on demand); without it the button would do nothing. */
320
+ .sh-search-m{display:none}
321
+ .sh-menu-nojs{display:inline-grid;width:44px;height:44px;text-decoration:none}
322
+ body:has(#sh-menu-on:target) .sh-menu{opacity:1;transform:none;visibility:visible;transition:opacity .18s ease,transform .18s var(--ease)}
323
+ body:has(#sh-menu-on:target) .sh-menu-nojs{display:none}
324
+ body:has(#sh-menu-on:target) .sh-menu-nojs-close{display:inline-grid;width:44px;height:44px;text-decoration:none}
325
+ }
307
326
  @media (prefers-reduced-motion:reduce){
308
327
  *,*::before,*::after{animation:none!important;transition:none!important}
309
328
  html{scroll-behavior:auto!important}
package/app/tags/page.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  import Link from "next/link";
2
2
  import { Suspense } from "react";
3
3
  import { posts } from "../lib/content";
4
- import { tagCounts, tagFilterHref } from "../lib/tag-filter";
4
+ import { siteHref } from "../lib/site-href";
5
+ import { articleTagHref, tagCounts } from "../lib/tag-filter";
5
6
  import { TagRedirect } from "../components/TagRedirect";
6
7
  import { siteDocumentTitle } from "../site.config";
7
8
 
@@ -25,7 +26,7 @@ export default function TagsPage() {
25
26
  <ul className="sh-tagchips sh-tagindex" aria-label="全部标签">
26
27
  {tags.map(([tag, count]) => (
27
28
  <li key={tag}>
28
- <Link className="sh-tagchip" href={tagFilterHref([tag])}>
29
+ <Link className="sh-tagchip" href={siteHref(articleTagHref(tag))}>
29
30
  #{tag}<span className="n num">{count}</span>
30
31
  </Link>
31
32
  </li>
@@ -12,6 +12,7 @@ import {
12
12
  getSiteUrl,
13
13
  webmentionApiEndpoint,
14
14
  } from "../../lib/site";
15
+ import { siteHref } from "../../lib/site-href";
15
16
 
16
17
  export function generateStaticParams() {
17
18
  return thoughts.map((thought) => ({ slug: thought.slug }));
@@ -26,7 +27,7 @@ export async function generateMetadata({
26
27
  const thought = findPublishedThought(slug);
27
28
  if (!thought) return {};
28
29
  const baseUrl = await getSiteUrl();
29
- const pathname = `/thoughts/${slug}`;
30
+ const pathname = siteHref(`/thoughts/${slug}`);
30
31
  const images = Array.isArray(thought.media)
31
32
  ? thought.media.filter((item) => item.type === "image").slice(0, 4)
32
33
  : [];
@@ -60,11 +61,11 @@ export default async function ThoughtPage({
60
61
  const thought = findPublishedThought(slug);
61
62
  if (!thought) notFound();
62
63
  const baseUrl = await getSiteUrl();
63
- const pathname = `/thoughts/${slug}`;
64
+ const pathname = siteHref(`/thoughts/${slug}`);
64
65
 
65
66
  return (
66
67
  <main className="page-shell thought-detail-page">
67
- <Link href="/thoughts" className="back-link">
68
+ <Link href={siteHref("/thoughts")} className="back-link">
68
69
  <ArrowLeft size={16} />
69
70
  返回随想
70
71
  </Link>
package/bin/garden.mjs CHANGED
@@ -32,7 +32,8 @@ function runNode(script, scriptArgs = [], { cwd = siteRoot, env } = {}) {
32
32
  env: env ?? siteEnv(),
33
33
  stdio: "inherit",
34
34
  });
35
- if (result.status) process.exit(result.status ?? 1);
35
+ // A non-zero exit or a signal (status null) stops the command with a failure.
36
+ if (result.status !== 0) process.exit(result.status || 1);
36
37
  }
37
38
 
38
39
  function resolveVinext() {
@@ -278,7 +279,8 @@ function runVinext(vinextArgs, { copyDist = false } = {}) {
278
279
  stdio: "inherit",
279
280
  });
280
281
  if (copyDist) publishDist(cwd);
281
- if (result.status) process.exit(result.status ?? 1);
282
+ // A non-zero exit or a signal (status null) stops the command with a failure.
283
+ if (result.status !== 0) process.exit(result.status || 1);
282
284
  }
283
285
 
284
286
  function copyTemplate(from, to) {
@@ -356,6 +358,7 @@ function help() {
356
358
  garden embed 拉取外链摘要
357
359
  garden links 重新拉取外链图标(build 时会自动做)
358
360
  garden images 读取新的远程图片尺寸,写入 image-sizes.json(build 时会自动做)
361
+ garden files 读取 garden file 里远程文件的大小,写入 file-sizes.json(build 时会自动做)
359
362
  garden init [目录] 创建只含稿和配置的新站点
360
363
  garden check <文章.md> 检查一篇博文的硬性结构
361
364
  garden ui <子命令> Intelligent UI:scan、check、apply、prompt、schema、catalog
@@ -385,6 +388,8 @@ switch (command) {
385
388
  if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/link-assets.mjs");
386
389
  // Remote image sizes: only new images are probed; the rest come from image-sizes.json.
387
390
  if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/image-sizes.mjs");
391
+ // Sizes of remote files in garden file blocks; failures fall back to file-sizes.json.
392
+ if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/file-sizes.mjs");
388
393
  runVinext(["build", ...rest], { copyDist: true });
389
394
  // Pages for old addresses listed under redirects in site.config.yaml (static export only).
390
395
  if (process.env.STATIC_EXPORT === "1") runNode("scripts/redirects.mjs");
@@ -395,6 +400,9 @@ switch (command) {
395
400
  case "images":
396
401
  runNode("scripts/image-sizes.mjs");
397
402
  break;
403
+ case "files":
404
+ runNode("scripts/file-sizes.mjs");
405
+ break;
398
406
  case "start":
399
407
  runVinext(["start", ...rest]);
400
408
  break;
package/next.config.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import type { NextConfig } from "next";
2
+ import { trailingSlashEnabled } from "./ui/garden/site-href.mjs";
2
3
 
3
4
  const nextConfig: NextConfig = {
4
5
  ...(process.env.STATIC_EXPORT === "1"
5
6
  ? {
6
7
  output: "export",
7
- trailingSlash: true,
8
8
  images: { unoptimized: true },
9
9
  }
10
10
  : {}),
11
+ // Always on in a static export; a server build opts in with TRAILING_SLASH=1. Links follow it (siteHref).
12
+ trailingSlash: trailingSlashEnabled(process.env),
11
13
  };
12
14
 
13
15
  export default nextConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -102,11 +102,13 @@
102
102
  "rehype-katex": "^7.0.1",
103
103
  "rehype-raw": "^7.0.0",
104
104
  "rehype-slug": "^6.0.0",
105
+ "rehype-stringify": "^10.0.1",
105
106
  "remark-breaks": "^4.0.0",
106
107
  "remark-cjk-friendly": "^2.3.1",
107
108
  "remark-gfm": "^4.0.1",
108
109
  "remark-math": "^6.0.0",
109
110
  "remark-parse": "^11.0.0",
111
+ "remark-rehype": "^11.1.2",
110
112
  "tailwindcss": "4.2.1",
111
113
  "typescript": "5.9.3",
112
114
  "unified": "^11.0.5",
@@ -15,6 +15,20 @@ import { resolveBrand } from "./brand.mjs";
15
15
  import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
16
16
  import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
17
17
  import { coverImageSet, loadSharp } from "./cover-images.mjs";
18
+ import { readFileSizeCache } from "./file-sizes.mjs";
19
+ import { previewDocument, previewFileName, renderFilePreview } from "./file-preview.mjs";
20
+ import { parseGardenBlock, parseGardenInfo, scanFences } from "../ui/garden/parse.mjs";
21
+ import {
22
+ fileIcon,
23
+ fileName,
24
+ formatLabel,
25
+ isRemoteSrc,
26
+ previewKind,
27
+ maskHtmlComments,
28
+ readFileProps,
29
+ } from "../ui/garden/file-block.mjs";
30
+ import { resolveRepoFile } from "../ui/garden/file-paths.mjs";
31
+ import { trailingSlashEnabled, withTrailingSlash } from "../ui/garden/site-href.mjs";
18
32
 
19
33
  function resolveSiteRoot() {
20
34
  if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
@@ -266,6 +280,80 @@ function rewriteWidgetFences(content, markdownPath, sourcePath) {
266
280
  );
267
281
  }
268
282
 
283
+ const remoteFileSizes = readFileSizeCache(root).files;
284
+
285
+ /** One file of a `garden file` fence, resolved for the page. */
286
+ function resolveFileEntry(file, markdownPath, sourcePath) {
287
+ const src = file.src;
288
+ const name = fileName(src);
289
+ const entry = {
290
+ name,
291
+ title: file.title || name,
292
+ ...(file.note ? { note: file.note } : {}),
293
+ format: formatLabel(src),
294
+ icon: fileIcon(src),
295
+ };
296
+ if (isRemoteSrc(src)) {
297
+ const size = remoteFileSizes[src]?.size;
298
+ return { ...entry, url: src, remote: true, preview: "", ...(size > 0 ? { size } : {}) };
299
+ }
300
+ if (src.startsWith("/")) {
301
+ // A site path: served as is, download only.
302
+ const file = path.join(root, "public", src.split(/[?#]/u, 1)[0]);
303
+ const size = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.statSync(file).size : 0;
304
+ return { ...entry, url: `${basePath}${src}`, remote: false, preview: "", ...(size > 0 ? { size } : {}) };
305
+ }
306
+ const resolved = resolveRepoFile(src, markdownPath, contentRoot);
307
+ if (resolved.error) fail(sourcePath, `garden file:${resolved.error}`);
308
+ const missingMessage = `garden file 找不到 ${src};src 相对这篇 Markdown 所在的目录`;
309
+ if (!fs.existsSync(resolved.path) || !fs.statSync(resolved.path).isFile()) fail(sourcePath, missingMessage);
310
+ const url = copyLocalAsset(src, markdownPath, sourcePath, { missingMessage });
311
+ const preview = previewKind(src, { remote: false });
312
+ const next = { ...entry, url, size: fs.statSync(resolved.path).size, remote: false, preview };
313
+ if (preview === "md") {
314
+ // Rendered now into its own file next to the copy; the page only links to it and the
315
+ // preview fetches it on demand. Images next to the Markdown file are copied too.
316
+ const { content } = matter(fs.readFileSync(resolved.path, "utf8"));
317
+ const html = renderFilePreview(rewriteMarkdownAssets(content.trim(), resolved.path, sourcePath));
318
+ const { destination } = assetFiles.get(url.split(/[?#]/u, 1)[0]);
319
+ fs.writeFileSync(previewFileName(destination), previewDocument(html, { title: entry.title }));
320
+ next.previewSrc = previewFileName(url.split(/[?#]/u, 1)[0]);
321
+ }
322
+ return next;
323
+ }
324
+
325
+ /**
326
+ * `garden file` fences: resolve every src next to the Markdown file, copy repo
327
+ * files to the public folder, and replace the YAML with `resolved:` data the
328
+ * page renders without reading the file system.
329
+ */
330
+ function rewriteFileFences(content, markdownPath, sourcePath) {
331
+ const fences = scanFences(maskHtmlComments(content)).filter((fence) => parseGardenInfo(fence.info)?.name === "file" && !fence.unclosed);
332
+ if (!fences.length) return content;
333
+ const lines = content.split("\n");
334
+ for (const fence of [...fences].reverse()) {
335
+ const block = parseGardenBlock(fence.info, fence.body);
336
+ if (block?.error) fail(sourcePath, `第 ${fence.line} 行 garden file:${block.error.message}`);
337
+ if (block.props.resolved) fail(sourcePath, `第 ${fence.line} 行 garden file:resolved 由构建生成,不要手写`);
338
+ const read = readFileProps(block.props);
339
+ if (read.errors.length) fail(sourcePath, `第 ${fence.line} 行 garden file:${read.errors.join(";")}`);
340
+ const resolved = {
341
+ layout: read.layout,
342
+ ...(read.title ? { title: read.title } : {}),
343
+ files: read.files.map((file) => resolveFileEntry(file, markdownPath, sourcePath)),
344
+ };
345
+ const indent = " ".repeat(fence.indent);
346
+ lines.splice(
347
+ fence.line - 1,
348
+ fence.endLine - fence.line + 1,
349
+ `${indent}${fence.marker}${fence.info}`,
350
+ `${indent}resolved: ${JSON.stringify(resolved)}`,
351
+ `${indent}${fence.marker}`,
352
+ );
353
+ }
354
+ return lines.join("\n");
355
+ }
356
+
269
357
  function rewriteUiFences(content, markdownPath, sourcePath, lineOffset) {
270
358
  const { content: transformed, errors } = transformUiContent(content, {
271
359
  mode: uiMode,
@@ -392,9 +480,13 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath, lin
392
480
  ? copyLocalAsset(data.poster, markdownPath, sourcePath)
393
481
  : undefined,
394
482
  media: normalizeMedia(data.media, markdownPath, sourcePath),
395
- content: rewriteWidgetFences(
396
- rewriteMarkdownAssets(
397
- rewriteUiFences(content.trim(), markdownPath, sourcePath, lineOffset + leadingLines(content)),
483
+ content: rewriteFileFences(
484
+ rewriteWidgetFences(
485
+ rewriteMarkdownAssets(
486
+ rewriteUiFences(content.trim(), markdownPath, sourcePath, lineOffset + leadingLines(content)),
487
+ markdownPath,
488
+ sourcePath,
489
+ ),
398
490
  markdownPath,
399
491
  sourcePath,
400
492
  ),
@@ -411,7 +503,9 @@ function readMarkdownTree(directory, kind) {
411
503
 
412
504
  return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
413
505
  const fullPath = path.join(directory, entry.name);
414
- if (entry.isDirectory()) return readMarkdownTree(fullPath, kind);
506
+ // `post.md` + `post/` is the post's own folder of files (garden html, garden file):
507
+ // Markdown inside it is a file to download or preview, not another post.
508
+ if (entry.isDirectory()) return fs.existsSync(`${fullPath}.md`) ? [] : readMarkdownTree(fullPath, kind);
415
509
  if (!entry.name.endsWith(".md")) return [];
416
510
 
417
511
  const raw = fs.readFileSync(fullPath, "utf8");
@@ -674,7 +768,7 @@ export const searchFull = ${JSON.stringify({ url: `${basePath}/${fullIndex.fileN
674
768
  // Header logo (inlined SVG) and icon URLs, from brand in site.config.yaml.
675
769
  export const brand = ${JSON.stringify(resolveBrand(root, siteConfig, basePath, (line) => console.warn(line)))};
676
770
  // Full URLs to this address render as internal links.
677
- export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath })};
771
+ export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath, trailingSlash: trailingSlashEnabled(process.env) })};
678
772
  `,
679
773
  );
680
774
  fs.mkdirSync(path.join(root, "public"), { recursive: true });
@@ -696,9 +790,15 @@ const publicRoot = path.join(root, "public");
696
790
  const columnEntryPath = (entry) => entry.path.startsWith(`${entry.column}/`)
697
791
  ? entry.path.slice(entry.column.length + 1)
698
792
  : entry.path;
793
+ // Page addresses in the feeds and the sitemap follow trailingSlash like every site link.
794
+ const pageAddress = (pathname) => withTrailingSlash(pathname, trailingSlashEnabled(process.env));
795
+ // A feed item's guid is how readers tell items apart: it keeps the form it has had since 0.5.14
796
+ // (`/blog/<path>/`, `/thoughts/<slug>/`) whatever the link looks like, or every reader would show
797
+ // the whole feed as new again.
798
+ const feedEntry = (entry, pathname) => ({ ...entry, path: pageAddress(pathname), guidPath: pathname });
699
799
  const feedEntries = [
700
- ...posts.map((entry) => ({ ...entry, path: `/blog/${entry.path}/` })),
701
- ...thoughts.map((entry) => ({ ...entry, path: `/thoughts/${entry.slug}/` })),
800
+ ...posts.map((entry) => feedEntry(entry, `/blog/${entry.path}/`)),
801
+ ...thoughts.map((entry) => feedEntry(entry, `/thoughts/${entry.slug}/`)),
702
802
  ].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
703
803
 
704
804
  const feedLastBuildDate = (entries) => new Date(entries.reduce(
@@ -711,7 +811,7 @@ function writeRssFeed(fileName, entries) {
711
811
  <item>
712
812
  <title>${escapeXml(entry.title)}</title>
713
813
  <link>${escapeXml(`${publicBaseUrl}${entry.path}`)}</link>
714
- <guid isPermaLink="true">${escapeXml(`${publicBaseUrl}${entry.path}`)}</guid>
814
+ <guid isPermaLink="true">${escapeXml(`${publicBaseUrl}${entry.guidPath}`)}</guid>
715
815
  <pubDate>${new Date(entry.date).toUTCString()}</pubDate>
716
816
  <description>${escapeXml(entry.description ?? contentToSearchText(entry.content).replace(/\s+/gu, " ").trim().slice(0, 180))}</description>
717
817
  ${(entry.tags ?? []).map((tag) => `<category>${escapeXml(tag)}</category>`).join("")}
@@ -735,14 +835,14 @@ function writeRssFeed(fileName, entries) {
735
835
  writeRssFeed("rss.xml", feedEntries);
736
836
  writeRssFeed(
737
837
  "posts.xml",
738
- posts.map((entry) => ({ ...entry, path: `/blog/${entry.path}/` })),
838
+ posts.map((entry) => feedEntry(entry, `/blog/${entry.path}/`)),
739
839
  );
740
840
  const sitemapEntries = [
741
841
  "/", "/blog/", "/thoughts/", "/columns/", "/about/",
742
842
  ...posts.map((entry) => `/blog/${entry.path}/`),
743
843
  ...columns.map((entry) => `/columns/${entry.column}/${columnEntryPath(entry)}/`),
744
844
  ...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
745
- ];
845
+ ].map(pageAddress);
746
846
  // Card URLs in published entries: the only pages the link-card Worker may read.
747
847
  fs.writeFileSync(
748
848
  path.join(publicRoot, "link-allowlist.json"),