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.
- package/app/a2/page.tsx +2 -1
- package/app/about/page.tsx +2 -1
- package/app/columns/[column]/[...path]/page.tsx +2 -1
- package/app/components/ArticleTags.tsx +6 -3
- package/app/components/BlogArchive.tsx +3 -2
- package/app/components/ContentEmptyState.tsx +2 -1
- package/app/components/MarkdownArticle.tsx +40 -3
- package/app/components/SiteHeader.tsx +18 -15
- package/app/components/TagRedirect.tsx +4 -3
- package/app/components/ThoughtCard.tsx +3 -2
- package/app/components/garden/FilePreview.tsx +176 -0
- package/app/components/garden/GardenBlock.tsx +4 -0
- package/app/components/garden/GardenFile.tsx +69 -0
- package/app/components/garden/file-icons.tsx +49 -0
- package/app/garden-modules.d.ts +1 -1
- package/app/layout.tsx +3 -0
- package/app/lib/content.ts +5 -3
- package/app/lib/markdown-plugins.ts +2 -11
- package/app/lib/site-href.ts +9 -0
- package/app/lib/tag-filter.ts +8 -0
- package/app/page.tsx +5 -4
- package/app/rss.xml/route.ts +6 -3
- package/app/sitemap.ts +4 -3
- package/app/styles/a2.css +72 -0
- package/app/styles/shell.css +21 -2
- package/app/tags/page.tsx +3 -2
- package/app/thoughts/[slug]/page.tsx +4 -3
- package/bin/garden.mjs +10 -2
- package/next.config.ts +3 -1
- package/package.json +3 -1
- package/scripts/build-content.mjs +110 -10
- package/scripts/check-post.mjs +45 -0
- package/scripts/file-preview.mjs +138 -0
- package/scripts/file-sizes.mjs +146 -0
- package/scripts/prepare-pages-output.mjs +7 -1
- package/scripts/redirects.mjs +80 -55
- package/templates/garden-blocks.md +11 -0
- package/templates/garden-snippets.plist +9 -0
- package/ui/core/transform.mjs +18 -0
- package/ui/garden/catalog.mjs +15 -1
- package/ui/garden/check.mjs +12 -0
- package/ui/garden/file-block.mjs +232 -0
- package/ui/garden/file-paths.mjs +65 -0
- package/ui/garden/hash-jump.mjs +37 -0
- package/ui/garden/internal-links.mjs +113 -0
- package/ui/garden/markdown-syntax.mjs +12 -0
- package/ui/garden/parse.mjs +1 -1
- package/ui/garden/same-site.mjs +27 -0
- package/ui/garden/site-href.mjs +62 -0
- package/ui/garden/site-nav.mjs +25 -0
- package/ui/garden/snippets.mjs +2 -2
- package/ui/garden/word-count.mjs +5 -0
- package/ui/preview/dist/garden-ui.esm.js +33 -33
- 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
|
))}
|
package/app/about/page.tsx
CHANGED
|
@@ -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,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { articleTagHref } from "../lib/tag-filter";
|
|
3
|
+
import { siteHref } from "../lib/site-href";
|
|
2
4
|
|
|
5
|
+
/** Tags under an article title. Link, so following one is a client navigation like the rest of the site. */
|
|
3
6
|
export function ArticleTags({ tags }: { tags?: string[] }) {
|
|
4
7
|
const list = (tags ?? []).filter(Boolean);
|
|
5
8
|
if (!list.length) return null;
|
|
@@ -7,9 +10,9 @@ export function ArticleTags({ tags }: { tags?: string[] }) {
|
|
|
7
10
|
<ul className="article-tagrow" aria-label="标签">
|
|
8
11
|
{list.map((tag) => (
|
|
9
12
|
<li key={tag}>
|
|
10
|
-
<
|
|
13
|
+
<Link className="tag-chip p-category" href={siteHref(articleTagHref(tag))}>
|
|
11
14
|
#{tag}
|
|
12
|
-
</
|
|
15
|
+
</Link>
|
|
13
16
|
</li>
|
|
14
17
|
))}
|
|
15
18
|
</ul>
|
|
@@ -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>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ReactNode,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { articleHeadingBase, articleUsesHeadingNumbers, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
|
|
12
|
+
import Link from "next/link";
|
|
12
13
|
import ReactMarkdown from "react-markdown";
|
|
13
14
|
import rehypeRaw from "rehype-raw";
|
|
14
15
|
import rehypeKatex from "rehype-katex";
|
|
@@ -20,17 +21,40 @@ import { MarkdownImage } from "./MarkdownImage";
|
|
|
20
21
|
import { CodeCopy } from "./garden/chrome";
|
|
21
22
|
import { CodeDiff } from "./garden/chrome";
|
|
22
23
|
import { GardenBlock } from "./garden/GardenBlock";
|
|
24
|
+
import { GardenFile } from "./garden/GardenFile";
|
|
25
|
+
import { readGardenPayload } from "../lib/remark-garden.mjs";
|
|
23
26
|
import { imageSizes, linkAssets } from "@garden/generated-content";
|
|
24
27
|
import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs";
|
|
25
28
|
import { siteConfig, siteLinks } from "@garden/site-config";
|
|
26
29
|
import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
|
|
30
|
+
import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
|
|
31
|
+
import { columnHref, columns, postHref, posts, thoughts } from "../lib/content";
|
|
27
32
|
import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
28
33
|
import { linkHost } from "../../scripts/link-meta-core.mjs";
|
|
29
34
|
import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
35
|
+
import { siteHref } from "../lib/site-href";
|
|
30
36
|
|
|
31
37
|
// Full URLs back to this site are internal links, written as root-relative paths.
|
|
32
38
|
const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
|
|
33
39
|
|
|
40
|
+
// Links to other pages of the site render as Link. A link to an old address listed under
|
|
41
|
+
// redirects is rewritten to the page it moved to; a link that lands on no known page stays a plain anchor.
|
|
42
|
+
const internalRoute = internalLinkResolver({
|
|
43
|
+
redirects: [
|
|
44
|
+
...readRedirects(siteConfig),
|
|
45
|
+
...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
|
|
46
|
+
],
|
|
47
|
+
routes: [
|
|
48
|
+
"/", "/blog", "/columns", "/thoughts", "/tags", "/about",
|
|
49
|
+
...posts.map(postHref),
|
|
50
|
+
...columns.map(columnHref),
|
|
51
|
+
...thoughts.map((thought) => `/thoughts/${thought.slug}`),
|
|
52
|
+
],
|
|
53
|
+
basePath: siteLinks?.basePath ?? "",
|
|
54
|
+
// Same shape as every other address in the site (the slash follows trailingSlash).
|
|
55
|
+
format: siteHref,
|
|
56
|
+
});
|
|
57
|
+
|
|
34
58
|
/** The site's favicon, downloaded at build time; nothing when there is none. */
|
|
35
59
|
function Favicon({ href }: { href: string }) {
|
|
36
60
|
const src = linkAssets?.favicons?.[linkHost(href)];
|
|
@@ -219,9 +243,12 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
219
243
|
standalone={(props.dataStandalone ?? props["data-standalone"]) === "true"}
|
|
220
244
|
/>
|
|
221
245
|
),
|
|
222
|
-
"garden-block": (props: { dataPayload?: string; "data-payload"?: string }) =>
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
},
|
|
225
252
|
"garden-codediff": (props: {
|
|
226
253
|
children?: ReactNode;
|
|
227
254
|
dataDiff?: string;
|
|
@@ -262,6 +289,16 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
262
289
|
const { href, external } = articleLink(written, sameSite);
|
|
263
290
|
const domProps = { ...props };
|
|
264
291
|
Reflect.deleteProperty(domProps, "node");
|
|
292
|
+
// A link to another page of the site navigates in place, like the header and lists do.
|
|
293
|
+
// No viewport prefetch: a long article can link to many pages.
|
|
294
|
+
const route = external || domProps.target || domProps.download !== undefined ? null : internalRoute(href);
|
|
295
|
+
if (route) {
|
|
296
|
+
return (
|
|
297
|
+
<Link href={route} prefetch={false} {...domProps}>
|
|
298
|
+
{children}
|
|
299
|
+
</Link>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
265
302
|
return (
|
|
266
303
|
<a
|
|
267
304
|
href={href}
|
|
@@ -16,8 +16,10 @@ import { brand, searchFull, searchIndexUrl } from "@garden/site-config";
|
|
|
16
16
|
import { createSearch } from "../../ui/garden/search-match.mjs";
|
|
17
17
|
import { cropMatchedText, highlightParts, noResultsText } from "../../ui/garden/search-preview.mjs";
|
|
18
18
|
import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
|
|
19
|
+
import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
|
|
19
20
|
import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
|
|
20
21
|
import { siteConfig } from "../site.config";
|
|
22
|
+
import { siteHref } from "../lib/site-href";
|
|
21
23
|
|
|
22
24
|
/** One entry of /search-index.json: titles, tags, dates and a short excerpt, never the full body. */
|
|
23
25
|
type SearchRecord = {
|
|
@@ -85,13 +87,7 @@ function loadSearchEngine() {
|
|
|
85
87
|
return engineLoad;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
const nav =
|
|
89
|
-
{ href: "/blog", label: "文章" },
|
|
90
|
-
{ href: "/thoughts", label: "随想" },
|
|
91
|
-
{ href: "/columns", label: "专栏" },
|
|
92
|
-
{ href: "/tags", label: "标签" },
|
|
93
|
-
{ href: "/about", label: "关于" },
|
|
94
|
-
];
|
|
90
|
+
const nav = SITE_NAV.map((item) => ({ ...item, href: siteHref(item.href) }));
|
|
95
91
|
|
|
96
92
|
function rangesFor(
|
|
97
93
|
matches: readonly SearchMatch[] | undefined,
|
|
@@ -302,16 +298,15 @@ export function SiteHeader() {
|
|
|
302
298
|
window.localStorage.setItem("theme", next ? "dark" : "light");
|
|
303
299
|
};
|
|
304
300
|
|
|
305
|
-
const current = (href: string) =>
|
|
306
|
-
if (href === "/blog") {
|
|
307
|
-
return pathname === "/blog" || pathname.startsWith("/blog/") || pathname.startsWith("/a2/");
|
|
308
|
-
}
|
|
309
|
-
return pathname === href || pathname.startsWith(`${href}/`);
|
|
310
|
-
};
|
|
301
|
+
const current = (href: string) => isCurrentNav(pathname, href);
|
|
311
302
|
|
|
312
303
|
return (
|
|
313
304
|
<>
|
|
314
|
-
|
|
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" />
|
|
309
|
+
<header className="sh-header" id="site-header">
|
|
315
310
|
<div className="sh-bar">
|
|
316
311
|
<Link href="/" className="sh-word">
|
|
317
312
|
{/* Build-time SVG from brand.logo, cleaned of scripts and handlers. */}
|
|
@@ -352,6 +347,14 @@ export function SiteHeader() {
|
|
|
352
347
|
<button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ? "关闭菜单" : "打开菜单"} onClick={() => setMenuOpen((open) => !open)}>
|
|
353
348
|
{menuOpen ? <X size={18} /> : <span aria-hidden="true">☰</span>}
|
|
354
349
|
</button>
|
|
350
|
+
{/* Without JavaScript the button above cannot open anything: these two links take its place
|
|
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="打开菜单">
|
|
353
|
+
<span aria-hidden="true">☰</span>
|
|
354
|
+
</a>
|
|
355
|
+
<a className="sh-icon sh-menu-nojs-close" href="#sh-menu-off" aria-label="关闭菜单">
|
|
356
|
+
<X size={18} />
|
|
357
|
+
</a>
|
|
355
358
|
</div>
|
|
356
359
|
</div>
|
|
357
360
|
<nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label="站点导航(移动端)">
|
|
@@ -431,7 +434,7 @@ export function SiteHeader() {
|
|
|
431
434
|
ref={(node) => {
|
|
432
435
|
resultRefs.current[index] = node;
|
|
433
436
|
}}
|
|
434
|
-
href={result.item.path}
|
|
437
|
+
href={siteHref(result.item.path)}
|
|
435
438
|
onClick={closeSearch}
|
|
436
439
|
onFocus={() => setActiveResult(index)}
|
|
437
440
|
role="option"
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
4
4
|
import { useEffect } from "react";
|
|
5
|
-
import { TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
|
|
5
|
+
import { ARTICLE_TAG_LIST, TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
|
|
6
|
+
import { siteHref } from "../lib/site-href";
|
|
6
7
|
|
|
7
|
-
/** Old `/tags?t=X` links open the same filter on `/blog
|
|
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) : "";
|
|
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
|
+
}
|
package/app/garden-modules.d.ts
CHANGED
|
@@ -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
|