hd-garden 0.5.14 → 0.5.16

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 (62) hide show
  1. package/app/a2/page.tsx +2 -1
  2. package/app/about/page.tsx +4 -1
  3. package/app/blog/[...path]/page.tsx +2 -1
  4. package/app/blog/page.tsx +2 -0
  5. package/app/columns/[column]/[...path]/page.tsx +4 -2
  6. package/app/columns/page.tsx +2 -0
  7. package/app/components/ArticleTags.tsx +2 -1
  8. package/app/components/BlogArchive.tsx +3 -2
  9. package/app/components/ContentEmptyState.tsx +2 -1
  10. package/app/components/MarkdownArticle.tsx +16 -6
  11. package/app/components/SiteHeader.tsx +10 -5
  12. package/app/components/TagRedirect.tsx +2 -1
  13. package/app/components/ThoughtCard.tsx +3 -2
  14. package/app/components/garden/FilePreview.tsx +194 -0
  15. package/app/components/garden/GardenBlock.tsx +4 -0
  16. package/app/components/garden/GardenFile.tsx +69 -0
  17. package/app/components/garden/file-icons.tsx +49 -0
  18. package/app/garden/page.tsx +2 -0
  19. package/app/garden-modules.d.ts +1 -1
  20. package/app/layout.tsx +5 -5
  21. package/app/lib/content.ts +5 -3
  22. package/app/lib/markdown-plugins.ts +2 -11
  23. package/app/lib/page-alternates.ts +14 -0
  24. package/app/lib/site-href.ts +9 -0
  25. package/app/page.tsx +7 -4
  26. package/app/rss.xml/route.ts +6 -3
  27. package/app/sitemap.ts +4 -3
  28. package/app/styles/a2.css +120 -2
  29. package/app/styles/shell.css +8 -7
  30. package/app/tags/page.tsx +4 -1
  31. package/app/thoughts/[slug]/page.tsx +6 -4
  32. package/app/thoughts/page.tsx +2 -0
  33. package/app/ui-parity/page.tsx +4 -0
  34. package/bin/garden.mjs +10 -2
  35. package/next.config.ts +3 -1
  36. package/package.json +3 -2
  37. package/scripts/build-content.mjs +110 -10
  38. package/scripts/check-post.mjs +45 -0
  39. package/scripts/file-preview.mjs +222 -0
  40. package/scripts/file-sizes.mjs +146 -0
  41. package/scripts/prepare-pages-output.mjs +7 -1
  42. package/scripts/redirects.mjs +77 -17
  43. package/templates/garden-blocks.md +11 -0
  44. package/templates/garden-snippets.plist +9 -0
  45. package/ui/core/transform.mjs +18 -0
  46. package/ui/garden/catalog.mjs +15 -1
  47. package/ui/garden/check.mjs +12 -0
  48. package/ui/garden/code-language.mjs +48 -0
  49. package/ui/garden/file-block.mjs +232 -0
  50. package/ui/garden/file-paths.mjs +65 -0
  51. package/ui/garden/hash-jump.mjs +37 -0
  52. package/ui/garden/heading-anchors.mjs +94 -0
  53. package/ui/garden/html-nesting.mjs +54 -0
  54. package/ui/garden/internal-links.mjs +4 -2
  55. package/ui/garden/markdown-syntax.mjs +12 -0
  56. package/ui/garden/parse.mjs +1 -1
  57. package/ui/garden/site-href.mjs +62 -0
  58. package/ui/garden/snippets.mjs +2 -2
  59. package/ui/garden/word-count.mjs +5 -0
  60. package/ui/preview/dist/garden-ui.esm.js +60 -60
  61. package/ui/preview/dist/garden-ui.iife.js +57 -57
  62. package/ui/react/components/media.tsx +2 -1
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,8 @@ 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";
7
+ import { pageAlternates } from "../lib/page-alternates";
6
8
 
7
9
  const about = siteConfig.pages.about;
8
10
  const heading = about.heading ?? "关于";
@@ -17,6 +19,7 @@ const cta = about.cta ?? { href: "/blog", label: "浏览博文" };
17
19
  export const metadata = {
18
20
  title: { absolute: siteConfig.name },
19
21
  description: about.subtitle || siteConfig.description,
22
+ alternates: pageAlternates("/about"),
20
23
  };
21
24
 
22
25
  export default function AboutPage() {
@@ -43,7 +46,7 @@ export default function AboutPage() {
43
46
  <li key={item}>{item}</li>
44
47
  ))}
45
48
  </ul>
46
- <Link href={cta.href}>
49
+ <Link href={siteHref(cta.href)}>
47
50
  {cta.label}
48
51
  <ArrowRight size={16} />
49
52
  </Link>
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import { CoverImage } from "../../components/CoverImage";
3
3
  import type { Metadata } from "next";
4
+ import { FEED_ALTERNATES } from "../../lib/page-alternates";
4
5
  import { notFound } from "next/navigation";
5
6
  import {
6
7
  extractHeadings,
@@ -42,7 +43,7 @@ export async function generateMetadata({
42
43
  return {
43
44
  title: post.title,
44
45
  description: post.description,
45
- alternates: { canonical: pathname },
46
+ alternates: { canonical: pathname, types: FEED_ALTERNATES },
46
47
  openGraph: {
47
48
  type: "article",
48
49
  url: absoluteUrl(pathname, baseUrl),
package/app/blog/page.tsx CHANGED
@@ -3,10 +3,12 @@ import { siteConfig, siteDocumentTitle } from "../site.config";
3
3
  import { BlogArchive, BlogArchiveFallback } from "../components/BlogArchive";
4
4
  import { posts } from "../lib/content";
5
5
  import { archivePosts, archiveSeries } from "../lib/archive";
6
+ import { pageAlternates } from "../lib/page-alternates";
6
7
 
7
8
  export const metadata = {
8
9
  title: siteDocumentTitle,
9
10
  description: siteConfig.pages.blog.subtitle || siteConfig.description,
11
+ alternates: pageAlternates("/blog"),
10
12
  };
11
13
 
12
14
  export default function BlogIndex() {
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import { CoverImage } from "../../../components/CoverImage";
3
3
  import type { Metadata } from "next";
4
+ import { FEED_ALTERNATES } from "../../../lib/page-alternates";
4
5
  import { notFound } from "next/navigation";
5
6
  import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
6
7
  import {
@@ -25,6 +26,7 @@ import {
25
26
  webmentionApiEndpoint,
26
27
  } from "../../../lib/site";
27
28
  import { githubUrl, siteConfig } from "../../../site.config";
29
+ import { siteHref } from "../../../lib/site-href";
28
30
 
29
31
  export function generateStaticParams() {
30
32
  return columns.map((entry) => ({
@@ -46,7 +48,7 @@ export async function generateMetadata({
46
48
  return {
47
49
  title: entry.title.replace(/^\d+\s*·\s*/u, ""),
48
50
  description: entry.description,
49
- alternates: { canonical: pathname },
51
+ alternates: { canonical: pathname, types: FEED_ALTERNATES },
50
52
  openGraph: {
51
53
  type: "article",
52
54
  url: absoluteUrl(pathname, baseUrl),
@@ -88,7 +90,7 @@ export default async function ColumnEntryPage({
88
90
  return (
89
91
  <main className="column-reading-page">
90
92
  <aside className="series-nav">
91
- <Link href="/columns" className="back-link">
93
+ <Link href={siteHref("/columns")} className="back-link">
92
94
  <ArrowLeft size={15} />
93
95
  所有专栏
94
96
  </Link>
@@ -3,10 +3,12 @@ import Link from "next/link";
3
3
  import { columnHref, columns } from "../lib/content";
4
4
  import { ContentEmptyState } from "../components/ContentEmptyState";
5
5
  import { siteConfig, siteDocumentTitle } from "../site.config";
6
+ import { pageAlternates } from "../lib/page-alternates";
6
7
 
7
8
  export const metadata = {
8
9
  title: siteDocumentTitle,
9
10
  description: siteConfig.pages.columns.subtitle || siteConfig.description,
11
+ alternates: pageAlternates("/columns"),
10
12
  };
11
13
 
12
14
  function day(value: string) {
@@ -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>
@@ -14,13 +14,15 @@ import ReactMarkdown from "react-markdown";
14
14
  import rehypeRaw from "rehype-raw";
15
15
  import rehypeKatex from "rehype-katex";
16
16
  import rehypeSlug from "rehype-slug";
17
- import rehypeAutolinkHeadings from "rehype-autolink-headings";
18
17
  import rehypeHighlight from "rehype-highlight";
18
+ import { rehypeHeadingAnchors } from "../../ui/garden/heading-anchors.mjs";
19
19
  import { MermaidDiagram } from "./MermaidDiagram";
20
20
  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,8 @@ 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";
36
+ import { codeLanguageLabel } from "../../ui/garden/code-language.mjs";
33
37
 
34
38
  // Full URLs back to this site are internal links, written as root-relative paths.
35
39
  const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
@@ -48,6 +52,8 @@ const internalRoute = internalLinkResolver({
48
52
  ...thoughts.map((thought) => `/thoughts/${thought.slug}`),
49
53
  ],
50
54
  basePath: siteLinks?.basePath ?? "",
55
+ // Same shape as every other address in the site (the slash follows trailingSlash).
56
+ format: siteHref,
51
57
  });
52
58
 
53
59
  /** The site's favicon, downloaded at build time; nothing when there is none. */
@@ -238,9 +244,12 @@ export function MarkdownArticle({ content }: { content: string }) {
238
244
  standalone={(props.dataStandalone ?? props["data-standalone"]) === "true"}
239
245
  />
240
246
  ),
241
- "garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => (
242
- <GardenBlock dataPayload={props.dataPayload ?? props["data-payload"]} />
243
- ),
247
+ "garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => {
248
+ const payload = props.dataPayload ?? props["data-payload"];
249
+ const block = readGardenPayload(payload);
250
+ if (block?.name === "file" && !block.error) return <GardenFile props={(block.props ?? {}) as Record<string, unknown>} />;
251
+ return <GardenBlock dataPayload={payload} />;
252
+ },
244
253
  "garden-codediff": (props: {
245
254
  children?: ReactNode;
246
255
  dataDiff?: string;
@@ -263,7 +272,8 @@ export function MarkdownArticle({ content }: { content: string }) {
263
272
  rehypeRaw,
264
273
  rehypeKatex,
265
274
  rehypeSlug,
266
- [rehypeAutolinkHeadings, { behavior: "wrap", properties: { className: ["h-anchor"] } }],
275
+ // Not rehype-autolink-headings "wrap": a heading with a link in it would nest <a> in <a> (React #418).
276
+ [rehypeHeadingAnchors, { className: ["h-anchor"] }],
267
277
  rehypeHighlight,
268
278
  ]}
269
279
  components={{
@@ -333,7 +343,7 @@ export function MarkdownArticle({ content }: { content: string }) {
333
343
 
334
344
  return (
335
345
  <CodeCopy>
336
- <pre className="code" {...preProps} data-language={language?.toUpperCase()}>
346
+ <pre className="code" {...preProps} data-language={codeLanguageLabel(language) || undefined}>
337
347
  {children}
338
348
  </pre>
339
349
  </CodeCopy>
@@ -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,194 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState, type FocusEvent, 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 frameRef = useRef<HTMLIFrameElement>(null);
36
+ const titleId = useId();
37
+
38
+ const close = useCallback(() => {
39
+ request.current?.abort();
40
+ setOpen(false);
41
+ trigger.current?.focus();
42
+ }, []);
43
+
44
+ const load = useCallback(() => {
45
+ if (!file.src) return;
46
+ request.current?.abort();
47
+ const controller = new AbortController();
48
+ request.current = controller;
49
+ setLoaded({ state: "loading" });
50
+ fetch(file.src, { signal: controller.signal })
51
+ .then((response) => {
52
+ if (!response.ok) throw new Error(String(response.status));
53
+ return response.text();
54
+ })
55
+ .then((text) => {
56
+ if (request.current !== controller) return;
57
+ const html = previewBody(text);
58
+ setLoaded(html ? { state: "ready", html } : { state: "error" });
59
+ })
60
+ .catch(() => {
61
+ if (request.current !== controller) return;
62
+ // Closed before it arrived: the next open starts again.
63
+ setLoaded(controller.signal.aborted ? { state: "idle" } : { state: "error" });
64
+ });
65
+ }, [file.src]);
66
+
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ const root = document.documentElement;
70
+ root.classList.add("ux-lock");
71
+ closeButton.current?.focus();
72
+ const onKey = (event: KeyboardEvent) => {
73
+ if (event.key === "Escape") {
74
+ event.preventDefault();
75
+ close();
76
+ return;
77
+ }
78
+ if (event.key !== "Tab" || !panel.current) return;
79
+ const focusable = [...panel.current.querySelectorAll<HTMLElement>("a[href], button, iframe, [tabindex]:not([tabindex='-1'])")].filter((element) => !element.hasAttribute("data-gf-sentinel"));
80
+ if (!focusable.length) return;
81
+ const first = focusable[0];
82
+ const last = focusable[focusable.length - 1];
83
+ if (event.shiftKey && document.activeElement === first) {
84
+ event.preventDefault();
85
+ last.focus();
86
+ } else if (!event.shiftKey && document.activeElement === last) {
87
+ event.preventDefault();
88
+ first.focus();
89
+ }
90
+ };
91
+ document.addEventListener("keydown", onKey);
92
+ return () => {
93
+ root.classList.remove("ux-lock");
94
+ document.removeEventListener("keydown", onKey);
95
+ };
96
+ }, [open, close]);
97
+
98
+ // Focus loops inside the drawer even when it starts in the iframe.
99
+ // After the iframe: Tab left it, so go round to the drawer's first control.
100
+ const fromAfter = () => {
101
+ panel.current?.querySelector<HTMLElement>(".gf-dh a[href], .gf-dh button")?.focus();
102
+ };
103
+ // Before the iframe: coming forward from the header, go on into the iframe;
104
+ // coming back out of it (Shift+Tab, or from outside), go to the close button.
105
+ const fromBefore = (event: FocusEvent<HTMLSpanElement>) => {
106
+ const from = event.relatedTarget as Node | null;
107
+ if (from && panel.current?.querySelector(".gf-dh")?.contains(from)) frameRef.current?.focus();
108
+ else closeButton.current?.focus();
109
+ };
110
+
111
+ // A plain click opens the drawer; a modified click (new tab, new window) keeps the link.
112
+ const openDrawer = (event: MouseEvent<HTMLAnchorElement>) => {
113
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
114
+ event.preventDefault();
115
+ setOpen(true);
116
+ // Fetched on first open and kept; a failed or interrupted load starts again.
117
+ if (file.preview === "md" && loaded.state !== "ready" && loaded.state !== "loading") load();
118
+ };
119
+
120
+ const frame = previewFrameProps(file) as { src: string; title: string; referrerPolicy: "no-referrer"; sandbox?: string } | null;
121
+ const drawer = open
122
+ ? createPortal(
123
+ <div className="gf-layer">
124
+ <div className="gf-scrim" onClick={close} aria-hidden="true" />
125
+ <aside ref={panel} className="gf-drawer" role="dialog" aria-modal="true" aria-labelledby={titleId} data-preview={file.preview}>
126
+ <div className="gf-dh">
127
+ <span className="gf-ico" aria-hidden="true"><FileGlyph kind={file.icon} /></span>
128
+ <div className="gf-main">
129
+ <div className="gf-name" id={titleId} title={file.title}>{file.title}</div>
130
+ {file.meta ? <div className="gf-meta">{file.meta}</div> : null}
131
+ </div>
132
+ <div className="gf-act">
133
+ <a className="gf-btn icon" href={file.url} download={file.name} aria-label={`下载 ${file.name}`} title="下载">
134
+ <ActionGlyph name="download" />
135
+ </a>
136
+ <a className="gf-btn icon" href={file.url} target="_blank" rel="noopener noreferrer" aria-label={`在新标签页打开 ${file.name}`} title="在新标签页打开">
137
+ <ActionGlyph name="external" />
138
+ </a>
139
+ <button ref={closeButton} type="button" className="gf-btn icon" onClick={close} aria-label="关闭预览" title="关闭">
140
+ <ActionGlyph name="close" />
141
+ </button>
142
+ </div>
143
+ </div>
144
+ {!frame ? (
145
+ <div className="gf-db" aria-busy={loaded.state === "loading" ? "true" : undefined}>
146
+ {loaded.state === "ready" ? (
147
+ <div className="markdown-body prose gf-doc" dangerouslySetInnerHTML={{ __html: loaded.html }} />
148
+ ) : loaded.state === "error" ? (
149
+ <div className="gf-state" role="alert">
150
+ <p>预览没能载入。</p>
151
+ <div className="gf-state-act">
152
+ <button type="button" className="gf-btn" onClick={() => load()}>重试</button>
153
+ <a className="gf-btn" href={file.url} download={file.name}>下载文件</a>
154
+ </div>
155
+ </div>
156
+ ) : (
157
+ <div className="gf-state" role="status">正在载入预览…</div>
158
+ )}
159
+ </div>
160
+ ) : (
161
+ <div className="gf-db gf-db-frame">
162
+ {/* Keys pressed inside the iframe never reach this page, so Tab out of it is caught here and kept in the drawer. */}
163
+ <span tabIndex={0} className="gf-sentinel" data-gf-sentinel="before" onFocus={fromBefore} />
164
+ <iframe ref={frameRef} className="gf-frame" {...frame} />
165
+ <span tabIndex={0} className="gf-sentinel" data-gf-sentinel="after" onFocus={fromAfter} />
166
+ </div>
167
+ )}
168
+ <p className="gf-hint">Esc 关闭</p>
169
+ </aside>
170
+ </div>,
171
+ document.body,
172
+ )
173
+ : null;
174
+
175
+ return (
176
+ <>
177
+ <a
178
+ ref={trigger}
179
+ href={file.preview === "md" && file.src ? file.src : file.url}
180
+ target="_blank"
181
+ rel="noopener noreferrer"
182
+ className="gf-btn gf-preview"
183
+ data-compact={compact ? "true" : undefined}
184
+ onClick={openDrawer}
185
+ aria-haspopup="dialog"
186
+ aria-label={`预览 ${file.name}`}
187
+ >
188
+ <ActionGlyph name="eye" />
189
+ <span className="gf-lb">预览</span>
190
+ </a>
191
+ {drawer}
192
+ </>
193
+ );
194
+ }
@@ -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
+ }