hd-garden 0.5.14 → 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 (51) 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 +2 -1
  5. package/app/components/BlogArchive.tsx +3 -2
  6. package/app/components/ContentEmptyState.tsx +2 -1
  7. package/app/components/MarkdownArticle.tsx +11 -3
  8. package/app/components/SiteHeader.tsx +10 -5
  9. package/app/components/TagRedirect.tsx +2 -1
  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/page.tsx +5 -4
  21. package/app/rss.xml/route.ts +6 -3
  22. package/app/sitemap.ts +4 -3
  23. package/app/styles/a2.css +72 -0
  24. package/app/styles/shell.css +8 -7
  25. package/app/tags/page.tsx +2 -1
  26. package/app/thoughts/[slug]/page.tsx +4 -3
  27. package/bin/garden.mjs +10 -2
  28. package/next.config.ts +3 -1
  29. package/package.json +3 -1
  30. package/scripts/build-content.mjs +110 -10
  31. package/scripts/check-post.mjs +45 -0
  32. package/scripts/file-preview.mjs +138 -0
  33. package/scripts/file-sizes.mjs +146 -0
  34. package/scripts/prepare-pages-output.mjs +7 -1
  35. package/scripts/redirects.mjs +77 -17
  36. package/templates/garden-blocks.md +11 -0
  37. package/templates/garden-snippets.plist +9 -0
  38. package/ui/core/transform.mjs +18 -0
  39. package/ui/garden/catalog.mjs +15 -1
  40. package/ui/garden/check.mjs +12 -0
  41. package/ui/garden/file-block.mjs +232 -0
  42. package/ui/garden/file-paths.mjs +65 -0
  43. package/ui/garden/hash-jump.mjs +37 -0
  44. package/ui/garden/internal-links.mjs +4 -2
  45. package/ui/garden/markdown-syntax.mjs +12 -0
  46. package/ui/garden/parse.mjs +1 -1
  47. package/ui/garden/site-href.mjs +62 -0
  48. package/ui/garden/snippets.mjs +2 -2
  49. package/ui/garden/word-count.mjs +5 -0
  50. package/ui/preview/dist/garden-ui.esm.js +33 -33
  51. package/ui/preview/dist/garden-ui.iife.js +32 -32
package/app/a2/page.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import Link from "next/link";
2
+ import { siteHref } from "../lib/site-href";
2
3
  import { a2Slugs, readA2Article } from "../lib/a2-preview";
3
4
 
4
5
  export const metadata = {
@@ -17,7 +18,7 @@ export default function A2Index() {
17
18
  <ul className="sh-rows">
18
19
  {articles.map((article) => (
19
20
  <li className="sh-row" key={article.slug}>
20
- <h3><Link href={`/a2/${article.slug}`}>{article.title}</Link></h3>
21
+ <h3><Link href={siteHref(`/a2/${article.slug}`)}>{article.title}</Link></h3>
21
22
  <p>{article.description}</p>
22
23
  </li>
23
24
  ))}
@@ -3,6 +3,7 @@ import { ArrowRight } from "@phosphor-icons/react/ssr";
3
3
  import { GithubProfile } from "../components/GithubProfile";
4
4
  import { PageIntro } from "../components/PageIntro";
5
5
  import { siteConfig } from "../site.config";
6
+ import { siteHref } from "../lib/site-href";
6
7
 
7
8
  const about = siteConfig.pages.about;
8
9
  const heading = about.heading ?? "关于";
@@ -43,7 +44,7 @@ export default function AboutPage() {
43
44
  <li key={item}>{item}</li>
44
45
  ))}
45
46
  </ul>
46
- <Link href={cta.href}>
47
+ <Link href={siteHref(cta.href)}>
47
48
  {cta.label}
48
49
  <ArrowRight size={16} />
49
50
  </Link>
@@ -25,6 +25,7 @@ import {
25
25
  webmentionApiEndpoint,
26
26
  } from "../../../lib/site";
27
27
  import { githubUrl, siteConfig } from "../../../site.config";
28
+ import { siteHref } from "../../../lib/site-href";
28
29
 
29
30
  export function generateStaticParams() {
30
31
  return columns.map((entry) => ({
@@ -88,7 +89,7 @@ export default async function ColumnEntryPage({
88
89
  return (
89
90
  <main className="column-reading-page">
90
91
  <aside className="series-nav">
91
- <Link href="/columns" className="back-link">
92
+ <Link href={siteHref("/columns")} className="back-link">
92
93
  <ArrowLeft size={15} />
93
94
  所有专栏
94
95
  </Link>
@@ -1,5 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { articleTagHref } from "../lib/tag-filter";
3
+ import { siteHref } from "../lib/site-href";
3
4
 
4
5
  /** Tags under an article title. Link, so following one is a client navigation like the rest of the site. */
5
6
  export function ArticleTags({ tags }: { tags?: string[] }) {
@@ -9,7 +10,7 @@ export function ArticleTags({ tags }: { tags?: string[] }) {
9
10
  <ul className="article-tagrow" aria-label="标签">
10
11
  {list.map((tag) => (
11
12
  <li key={tag}>
12
- <Link className="tag-chip p-category" href={articleTagHref(tag)}>
13
+ <Link className="tag-chip p-category" href={siteHref(articleTagHref(tag))}>
13
14
  #{tag}
14
15
  </Link>
15
16
  </li>
@@ -16,6 +16,7 @@ import {
16
16
  visibleTagOptions,
17
17
  } from "../lib/tag-filter";
18
18
  import { ContentEmptyState } from "./ContentEmptyState";
19
+ import { siteHref } from "../lib/site-href";
19
20
 
20
21
  function day(value: string) {
21
22
  const date = new Date(value);
@@ -29,8 +30,8 @@ function day(value: string) {
29
30
  }
30
31
 
31
32
  // Kept local: importing ../lib/content here would pull every post body into the client bundle.
32
- const postHref = (post: Pick<ArchivePost, "path">) => `/blog/${post.path}`;
33
- const columnIndexHref = (slug: string) => `/columns/#${slug}`;
33
+ const postHref = (post: Pick<ArchivePost, "path">) => siteHref(`/blog/${post.path}`);
34
+ const columnIndexHref = (slug: string) => siteHref(`/columns/#${slug}`);
34
35
 
35
36
  function ColumnLabel({ column }: { column?: ArchivePost["column"] }) {
36
37
  return column ? <Link className="cat" href={columnIndexHref(column.slug)}>{column.title}</Link> : null;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { ArrowRight, Article, Books, Quotes } from "@phosphor-icons/react/ssr";
5
+ import { siteHref } from "../lib/site-href";
5
6
 
6
7
  const emptyContent = {
7
8
  posts: {
@@ -44,7 +45,7 @@ export function ContentEmptyState({ kind }: { kind: keyof typeof emptyContent })
44
45
  <h2 id={`${kind}-empty-title`}>{content.title}</h2>
45
46
  <p>{content.description}</p>
46
47
  </div>
47
- <Link href={content.actionHref}>
48
+ <Link href={siteHref(content.actionHref)}>
48
49
  {content.actionLabel}
49
50
  <ArrowRight size={17} />
50
51
  </Link>
@@ -21,6 +21,8 @@ import { MarkdownImage } from "./MarkdownImage";
21
21
  import { CodeCopy } from "./garden/chrome";
22
22
  import { CodeDiff } from "./garden/chrome";
23
23
  import { GardenBlock } from "./garden/GardenBlock";
24
+ import { GardenFile } from "./garden/GardenFile";
25
+ import { readGardenPayload } from "../lib/remark-garden.mjs";
24
26
  import { imageSizes, linkAssets } from "@garden/generated-content";
25
27
  import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs";
26
28
  import { siteConfig, siteLinks } from "@garden/site-config";
@@ -30,6 +32,7 @@ import { columnHref, columns, postHref, posts, thoughts } from "../lib/content";
30
32
  import { LinkCardRefresh } from "./LinkCardRefresh";
31
33
  import { linkHost } from "../../scripts/link-meta-core.mjs";
32
34
  import { cardDate } from "../../ui/garden/card-refresh.mjs";
35
+ import { siteHref } from "../lib/site-href";
33
36
 
34
37
  // Full URLs back to this site are internal links, written as root-relative paths.
35
38
  const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
@@ -48,6 +51,8 @@ const internalRoute = internalLinkResolver({
48
51
  ...thoughts.map((thought) => `/thoughts/${thought.slug}`),
49
52
  ],
50
53
  basePath: siteLinks?.basePath ?? "",
54
+ // Same shape as every other address in the site (the slash follows trailingSlash).
55
+ format: siteHref,
51
56
  });
52
57
 
53
58
  /** The site's favicon, downloaded at build time; nothing when there is none. */
@@ -238,9 +243,12 @@ export function MarkdownArticle({ content }: { content: string }) {
238
243
  standalone={(props.dataStandalone ?? props["data-standalone"]) === "true"}
239
244
  />
240
245
  ),
241
- "garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => (
242
- <GardenBlock dataPayload={props.dataPayload ?? props["data-payload"]} />
243
- ),
246
+ "garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => {
247
+ const payload = props.dataPayload ?? props["data-payload"];
248
+ const block = readGardenPayload(payload);
249
+ if (block?.name === "file" && !block.error) return <GardenFile props={(block.props ?? {}) as Record<string, unknown>} />;
250
+ return <GardenBlock dataPayload={payload} />;
251
+ },
244
252
  "garden-codediff": (props: {
245
253
  children?: ReactNode;
246
254
  dataDiff?: string;
@@ -19,6 +19,7 @@ import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
19
19
  import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
20
20
  import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
21
21
  import { siteConfig } from "../site.config";
22
+ import { siteHref } from "../lib/site-href";
22
23
 
23
24
  /** One entry of /search-index.json: titles, tags, dates and a short excerpt, never the full body. */
24
25
  type SearchRecord = {
@@ -86,7 +87,7 @@ function loadSearchEngine() {
86
87
  return engineLoad;
87
88
  }
88
89
 
89
- const nav = SITE_NAV;
90
+ const nav = SITE_NAV.map((item) => ({ ...item, href: siteHref(item.href) }));
90
91
 
91
92
  function rangesFor(
92
93
  matches: readonly SearchMatch[] | undefined,
@@ -301,6 +302,10 @@ export function SiteHeader() {
301
302
 
302
303
  return (
303
304
  <>
305
+ {/* Targets for the no-JavaScript menu links below. Fixed and outside the sticky header, so
306
+ following #sh-menu-on or #sh-menu-off never scrolls the page (the TOC drawer works the same way). */}
307
+ <span className="sh-menu-target" id="sh-menu-on" aria-hidden="true" />
308
+ <span className="sh-menu-target" id="sh-menu-off" aria-hidden="true" />
304
309
  <header className="sh-header" id="site-header">
305
310
  <div className="sh-bar">
306
311
  <Link href="/" className="sh-word">
@@ -343,11 +348,11 @@ export function SiteHeader() {
343
348
  {menuOpen ? <X size={18} /> : <span aria-hidden="true">☰</span>}
344
349
  </button>
345
350
  {/* Without JavaScript the button above cannot open anything: these two links take its place
346
- and open or close the menu through :target, like the TOC drawer (shell.css). */}
347
- <a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu" aria-label="打开菜单">
351
+ and open or close the menu through :target on the fixed anchors above (shell.css). */}
352
+ <a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu-on" aria-label="打开菜单">
348
353
  <span aria-hidden="true">☰</span>
349
354
  </a>
350
- <a className="sh-icon sh-menu-nojs-close" href="#site-header" aria-label="关闭菜单">
355
+ <a className="sh-icon sh-menu-nojs-close" href="#sh-menu-off" aria-label="关闭菜单">
351
356
  <X size={18} />
352
357
  </a>
353
358
  </div>
@@ -429,7 +434,7 @@ export function SiteHeader() {
429
434
  ref={(node) => {
430
435
  resultRefs.current[index] = node;
431
436
  }}
432
- href={result.item.path}
437
+ href={siteHref(result.item.path)}
433
438
  onClick={closeSearch}
434
439
  onFocus={() => setActiveResult(index)}
435
440
  role="option"
@@ -3,13 +3,14 @@
3
3
  import { useRouter, useSearchParams } from "next/navigation";
4
4
  import { useEffect } from "react";
5
5
  import { ARTICLE_TAG_LIST, TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
6
+ import { siteHref } from "../lib/site-href";
6
7
 
7
8
  /** Old `/tags?t=X` links open the same filter on `/blog/?t=X`. */
8
9
  export function TagRedirect() {
9
10
  const router = useRouter();
10
11
  const params = useSearchParams();
11
12
  const tags = selectedTags(params.getAll(TAG_PARAM));
12
- const target = tags.length ? tagFilterHref(tags, ARTICLE_TAG_LIST) : "";
13
+ const target = tags.length ? siteHref(tagFilterHref(tags, ARTICLE_TAG_LIST)) : "";
13
14
  useEffect(() => {
14
15
  if (target) router.replace(target);
15
16
  }, [router, target]);
@@ -14,6 +14,7 @@ import {
14
14
  } from "../lib/content";
15
15
  import { githubUrl, siteConfig } from "../site.config";
16
16
  import { MarkdownArticle } from "./MarkdownArticle";
17
+ import { siteHref } from "../lib/site-href";
17
18
 
18
19
  const icons = {
19
20
  image: ImageSquare,
@@ -68,7 +69,7 @@ export function ThoughtCard({
68
69
 
69
70
  return (
70
71
  <article id={thought.slug} className="thought-card h-entry">
71
- <Link className="u-url sr-only" href={`/thoughts/${thought.slug}`}>
72
+ <Link className="u-url sr-only" href={siteHref(`/thoughts/${thought.slug}`)}>
72
73
  {thought.title} 永久链接
73
74
  </Link>
74
75
  <a
@@ -232,7 +233,7 @@ export function ThoughtCard({
232
233
  )}
233
234
 
234
235
  {showPermalink && (
235
- <Link className="thought-permalink" href={`/thoughts/${thought.slug}`}>
236
+ <Link className="thought-permalink" href={siteHref(`/thoughts/${thought.slug}`)}>
236
237
  永久链接
237
238
  <ArrowUpRight size={15} />
238
239
  </Link>
@@ -0,0 +1,176 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState, type MouseEvent } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { previewBody, previewFrameProps } from "../../../ui/garden/file-block.mjs";
6
+ import { ActionGlyph, FileGlyph } from "./file-icons";
7
+
8
+ export type PreviewFile = {
9
+ title: string;
10
+ name: string;
11
+ meta: string;
12
+ icon: string;
13
+ url: string;
14
+ preview: "md" | "html" | "pdf";
15
+ /** Markdown only: the preview rendered at build time, in its own file. */
16
+ src?: string;
17
+ };
18
+
19
+ type Loaded = { state: "idle" | "loading" | "error" } | { state: "ready"; html: string };
20
+
21
+ /**
22
+ * The 预览 control and its drawer. Markdown is fetched from its prerendered
23
+ * file the first time the drawer opens (the page does not carry it); HTML runs
24
+ * in a sandbox without the site's origin; PDF uses the browser's own viewer.
25
+ * The control is a link: without JavaScript it opens the same preview (or the
26
+ * file) in a new tab, next to the card's download link.
27
+ */
28
+ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boolean }) {
29
+ const [open, setOpen] = useState(false);
30
+ const [loaded, setLoaded] = useState<Loaded>({ state: "idle" });
31
+ const trigger = useRef<HTMLAnchorElement>(null);
32
+ const request = useRef<AbortController | null>(null);
33
+ const panel = useRef<HTMLElement>(null);
34
+ const closeButton = useRef<HTMLButtonElement>(null);
35
+ const titleId = useId();
36
+
37
+ const close = useCallback(() => {
38
+ request.current?.abort();
39
+ setOpen(false);
40
+ trigger.current?.focus();
41
+ }, []);
42
+
43
+ const load = useCallback(() => {
44
+ if (!file.src) return;
45
+ request.current?.abort();
46
+ const controller = new AbortController();
47
+ request.current = controller;
48
+ setLoaded({ state: "loading" });
49
+ fetch(file.src, { signal: controller.signal })
50
+ .then((response) => {
51
+ if (!response.ok) throw new Error(String(response.status));
52
+ return response.text();
53
+ })
54
+ .then((text) => {
55
+ if (request.current !== controller) return;
56
+ const html = previewBody(text);
57
+ setLoaded(html ? { state: "ready", html } : { state: "error" });
58
+ })
59
+ .catch(() => {
60
+ if (request.current !== controller) return;
61
+ // Closed before it arrived: the next open starts again.
62
+ setLoaded(controller.signal.aborted ? { state: "idle" } : { state: "error" });
63
+ });
64
+ }, [file.src]);
65
+
66
+ useEffect(() => {
67
+ if (!open) return;
68
+ const root = document.documentElement;
69
+ root.classList.add("ux-lock");
70
+ closeButton.current?.focus();
71
+ const onKey = (event: KeyboardEvent) => {
72
+ if (event.key === "Escape") {
73
+ event.preventDefault();
74
+ close();
75
+ return;
76
+ }
77
+ if (event.key !== "Tab" || !panel.current) return;
78
+ const focusable = [...panel.current.querySelectorAll<HTMLElement>("a[href], button, iframe, [tabindex]:not([tabindex='-1'])")];
79
+ if (!focusable.length) return;
80
+ const first = focusable[0];
81
+ const last = focusable[focusable.length - 1];
82
+ if (event.shiftKey && document.activeElement === first) {
83
+ event.preventDefault();
84
+ last.focus();
85
+ } else if (!event.shiftKey && document.activeElement === last) {
86
+ event.preventDefault();
87
+ first.focus();
88
+ }
89
+ };
90
+ document.addEventListener("keydown", onKey);
91
+ return () => {
92
+ root.classList.remove("ux-lock");
93
+ document.removeEventListener("keydown", onKey);
94
+ };
95
+ }, [open, close]);
96
+
97
+ // A plain click opens the drawer; a modified click (new tab, new window) keeps the link.
98
+ const openDrawer = (event: MouseEvent<HTMLAnchorElement>) => {
99
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
100
+ event.preventDefault();
101
+ setOpen(true);
102
+ // Fetched on first open and kept; a failed or interrupted load starts again.
103
+ if (file.preview === "md" && loaded.state !== "ready" && loaded.state !== "loading") load();
104
+ };
105
+
106
+ const frame = previewFrameProps(file) as { src: string; title: string; referrerPolicy: "no-referrer"; sandbox?: string } | null;
107
+ const drawer = open
108
+ ? createPortal(
109
+ <div className="gf-layer">
110
+ <div className="gf-scrim" onClick={close} aria-hidden="true" />
111
+ <aside ref={panel} className="gf-drawer" role="dialog" aria-modal="true" aria-labelledby={titleId} data-preview={file.preview}>
112
+ <div className="gf-dh">
113
+ <span className="gf-ico" aria-hidden="true"><FileGlyph kind={file.icon} /></span>
114
+ <div className="gf-main">
115
+ <div className="gf-name" id={titleId} title={file.title}>{file.title}</div>
116
+ {file.meta ? <div className="gf-meta">{file.meta}</div> : null}
117
+ </div>
118
+ <div className="gf-act">
119
+ <a className="gf-btn icon" href={file.url} download={file.name} aria-label={`下载 ${file.name}`} title="下载">
120
+ <ActionGlyph name="download" />
121
+ </a>
122
+ <a className="gf-btn icon" href={file.url} target="_blank" rel="noopener noreferrer" aria-label={`在新标签页打开 ${file.name}`} title="在新标签页打开">
123
+ <ActionGlyph name="external" />
124
+ </a>
125
+ <button ref={closeButton} type="button" className="gf-btn icon" onClick={close} aria-label="关闭预览" title="关闭">
126
+ <ActionGlyph name="close" />
127
+ </button>
128
+ </div>
129
+ </div>
130
+ {!frame ? (
131
+ <div className="gf-db" aria-busy={loaded.state === "loading" ? "true" : undefined}>
132
+ {loaded.state === "ready" ? (
133
+ <div className="markdown-body prose gf-doc" dangerouslySetInnerHTML={{ __html: loaded.html }} />
134
+ ) : loaded.state === "error" ? (
135
+ <div className="gf-state" role="alert">
136
+ <p>预览没能载入。</p>
137
+ <div className="gf-state-act">
138
+ <button type="button" className="gf-btn" onClick={() => load()}>重试</button>
139
+ <a className="gf-btn" href={file.url} download={file.name}>下载文件</a>
140
+ </div>
141
+ </div>
142
+ ) : (
143
+ <div className="gf-state" role="status">正在载入预览…</div>
144
+ )}
145
+ </div>
146
+ ) : (
147
+ <div className="gf-db gf-db-frame">
148
+ <iframe className="gf-frame" {...frame} />
149
+ </div>
150
+ )}
151
+ </aside>
152
+ </div>,
153
+ document.body,
154
+ )
155
+ : null;
156
+
157
+ return (
158
+ <>
159
+ <a
160
+ ref={trigger}
161
+ href={file.preview === "md" && file.src ? file.src : file.url}
162
+ target="_blank"
163
+ rel="noopener noreferrer"
164
+ className="gf-btn gf-preview"
165
+ data-compact={compact ? "true" : undefined}
166
+ onClick={openDrawer}
167
+ aria-haspopup="dialog"
168
+ aria-label={`预览 ${file.name}`}
169
+ >
170
+ <ActionGlyph name="eye" />
171
+ <span className="gf-lb">预览</span>
172
+ </a>
173
+ {drawer}
174
+ </>
175
+ );
176
+ }
@@ -14,6 +14,7 @@ import {
14
14
  GardenTimeline,
15
15
  GardenUnknown,
16
16
  } from "./blocks";
17
+ import { GardenFile } from "./GardenFile";
17
18
 
18
19
  export function GardenBlock({ dataPayload }: { dataPayload?: string }) {
19
20
  const block = readGardenPayload(dataPayload);
@@ -39,6 +40,9 @@ export function GardenBlock({ dataPayload }: { dataPayload?: string }) {
39
40
  return <GardenHtml props={props}>{String(block.children ?? "")}</GardenHtml>;
40
41
  case "interactive":
41
42
  return <GardenInteractive block={{ id: block.id, props, children: block.children }} />;
43
+ case "file":
44
+ // Post bodies render file cards on the server (MarkdownArticle); this path has no Markdown preview.
45
+ return <GardenFile props={props} />;
42
46
  default:
43
47
  return <GardenUnknown name={String(block.name)} />;
44
48
  }
@@ -0,0 +1,69 @@
1
+ // Server component: the card for `garden file`. Only the 预览 control is a client island.
2
+ import { blockFiles, fileMeta } from "../../../ui/garden/file-block.mjs";
3
+ import { ActionGlyph, FileGlyph, safeFileHref } from "./file-icons";
4
+ import { FilePreview, type PreviewFile } from "./FilePreview";
5
+
6
+ type FileEntry = {
7
+ name?: string;
8
+ title?: string;
9
+ note?: string;
10
+ url?: string;
11
+ format?: string;
12
+ size?: number;
13
+ icon?: string;
14
+ remote?: boolean;
15
+ preview?: string;
16
+ /** Markdown: the prerendered preview's address (fetched when the preview opens). */
17
+ previewSrc?: string;
18
+ };
19
+
20
+ const PREVIEWS = new Set(["md", "html", "pdf"]);
21
+
22
+ export function GardenFile({ props }: { props: Record<string, unknown> }) {
23
+ const block = blockFiles(props) as { layout?: string; title?: string; files: FileEntry[] };
24
+ const files = block.files.filter((file) => safeFileHref(file.url));
25
+ if (!files.length) return null;
26
+ const group = block.layout === "group";
27
+ return (
28
+ <div className={group ? "gf group" : "gf single"} data-garden-file="">
29
+ {group && block.title ? <div className="gf-title">{block.title}</div> : null}
30
+ {files.map((file, index) => {
31
+ const href = safeFileHref(file.url);
32
+ const name = file.name || file.title || "文件";
33
+ const title = file.title || name;
34
+ const meta = fileMeta(file);
35
+ const previewSrc = file.preview === "md" ? safeFileHref(file.previewSrc) : "";
36
+ const preview = !file.remote && PREVIEWS.has(file.preview ?? "") && (file.preview !== "md" || previewSrc) ? (file.preview as PreviewFile["preview"]) : null;
37
+ const previewFile: PreviewFile | null = preview ? { title, name, meta, icon: file.icon ?? "file", url: href, preview, ...(previewSrc ? { src: previewSrc } : {}) } : null;
38
+ return (
39
+ <div className="gf-row" key={`${href}-${index}`} data-remote={file.remote ? "true" : undefined}>
40
+ <span className="gf-ico" aria-hidden="true"><FileGlyph kind={file.icon} /></span>
41
+ <div className="gf-main">
42
+ <div className="gf-name" title={title}>{title}</div>
43
+ {file.note ? <div className="gf-desc">{file.note}</div> : null}
44
+ {meta ? <div className="gf-meta">{meta}</div> : null}
45
+ </div>
46
+ <div className="gf-act">
47
+ {previewFile ? <FilePreview file={previewFile} compact={group} /> : null}
48
+ {file.remote ? (
49
+ <a className="gf-btn icon" href={href} target="_blank" rel="noopener noreferrer" aria-label={`在新标签页打开 ${name}`} title="在新标签页打开">
50
+ <ActionGlyph name="external" />
51
+ </a>
52
+ ) : null}
53
+ {group ? (
54
+ <a className="gf-btn icon" href={href} download={file.remote ? undefined : name} aria-label={`下载 ${name}`} title="下载">
55
+ <ActionGlyph name="download" />
56
+ </a>
57
+ ) : (
58
+ <a className="gf-btn pri" href={href} download={file.remote ? undefined : name} aria-label={`下载 ${name}`}>
59
+ <ActionGlyph name="download" />
60
+ <span className="gf-lb">下载</span>
61
+ </a>
62
+ )}
63
+ </div>
64
+ </div>
65
+ );
66
+ })}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,49 @@
1
+ export { safeFileHref } from "../../../ui/garden/file-block.mjs";
2
+
3
+ // Icons for `garden file`. No hooks: used by the server card and the client drawer alike.
4
+ const PAGE = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z";
5
+ const FOLD = "M14 3v5h5";
6
+ const MARKS: Record<string, string> = {
7
+ doc: "M9 13h6M9 17h4",
8
+ code: "m10 12-2 2 2 2M14 12l2 2-2 2",
9
+ archive: "M11 6h1M11 9h1M11 12h1M10.5 15h2v3h-2z",
10
+ file: "",
11
+ };
12
+ const GLYPHS: Record<string, string[]> = {
13
+ download: ["M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19h14"],
14
+ eye: ["M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"],
15
+ external: ["M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"],
16
+ close: ["M6 6l12 12M18 6 6 18"],
17
+ };
18
+
19
+ const svgProps = {
20
+ className: "gf-i",
21
+ viewBox: "0 0 24 24",
22
+ fill: "none",
23
+ stroke: "currentColor",
24
+ strokeWidth: 1.7,
25
+ strokeLinecap: "round" as const,
26
+ strokeLinejoin: "round" as const,
27
+ "aria-hidden": true,
28
+ focusable: false,
29
+ };
30
+
31
+ export function FileGlyph({ kind }: { kind?: string }) {
32
+ const mark = MARKS[kind ?? "file"] ?? "";
33
+ return (
34
+ <svg {...svgProps}>
35
+ <path d={PAGE} />
36
+ <path d={FOLD} />
37
+ {mark ? <path d={mark} /> : null}
38
+ </svg>
39
+ );
40
+ }
41
+
42
+ export function ActionGlyph({ name }: { name: keyof typeof GLYPHS }) {
43
+ return (
44
+ <svg {...svgProps}>
45
+ {GLYPHS[name].map((d) => <path key={d} d={d} />)}
46
+ {name === "eye" ? <circle cx="12" cy="12" r="2.8" /> : null}
47
+ </svg>
48
+ );
49
+ }
@@ -68,7 +68,7 @@ declare module "@garden/site-config" {
68
68
  /** The full-text index file and the hash its contents are cached under. */
69
69
  export const searchFull: { url: string; hash: string };
70
70
  /** The site address (site.config.yaml `url`, else SITE_URL) and base path, for spotting links back to the site. */
71
- export const siteLinks: { address: string; basePath: string };
71
+ export const siteLinks: { address: string; basePath: string; trailingSlash?: boolean };
72
72
  /** Header logo as inline SVG ("" when hidden) and icon URLs, from brand in site.config.yaml. */
73
73
  export const brand: { logo: string; favicon: string; appleTouchIcon: string };
74
74
  export const siteConfig: {
package/app/layout.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from "./lib/site";
13
13
  import { githubUrl, siteConfig } from "./site.config";
14
14
  import { brand } from "@garden/site-config";
15
+ import { HASH_JUMP_SCRIPT } from "../ui/garden/hash-jump.mjs";
15
16
 
16
17
  export async function generateMetadata(): Promise<Metadata> {
17
18
  const siteUrl = await getSiteUrl();
@@ -87,6 +88,8 @@ export default function RootLayout({
87
88
  '(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||(!t&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light";}catch(e){}})();',
88
89
  }}
89
90
  />
91
+ {/* Before the router's scripts: keeps #links in a post from refetching the page (ui/garden/hash-jump.mjs). */}
92
+ <script dangerouslySetInnerHTML={{ __html: HASH_JUMP_SCRIPT }} />
90
93
  {mentionEndpoint && <link rel="webmention" href={mentionEndpoint} />}
91
94
  {analyticsDomain && (
92
95
  <script
@@ -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);