hd-garden 0.5.15 → 0.5.17
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/README.md +2 -0
- package/app/about/page.tsx +2 -0
- package/app/blog/[...path]/page.tsx +2 -1
- package/app/blog/page.tsx +2 -0
- package/app/columns/[column]/[...path]/page.tsx +2 -1
- package/app/columns/page.tsx +2 -0
- package/app/components/MarkdownArticle.tsx +6 -4
- package/app/components/garden/FilePreview.tsx +21 -3
- package/app/garden/page.tsx +2 -0
- package/app/globals.css +1 -1
- package/app/layout.tsx +5 -9
- package/app/lib/page-alternates.ts +14 -0
- package/app/page.tsx +2 -0
- package/app/styles/a2.css +49 -2
- package/app/tags/page.tsx +2 -0
- package/app/thoughts/[slug]/page.tsx +2 -1
- package/app/thoughts/page.tsx +2 -0
- package/app/ui-parity/page.tsx +4 -0
- package/package.json +1 -2
- package/scripts/check-lockfile-registry.mjs +35 -0
- package/scripts/file-preview.mjs +87 -3
- package/ui/garden/code-language.mjs +62 -0
- package/ui/garden/file-block.mjs +14 -14
- package/ui/garden/heading-anchors.mjs +94 -0
- package/ui/garden/html-nesting.mjs +54 -0
- package/ui/preview/dist/garden-ui.esm.js +52 -52
- package/ui/preview/dist/garden-ui.iife.js +52 -52
- package/ui/react/components/media.tsx +2 -1
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ npm install
|
|
|
14
14
|
|
|
15
15
|
推荐把 GitHub 仓库命名为 `<你的 GitHub 用户名>.github.io`,并在 **Settings → Pages** 把 Source 设为 **GitHub Actions**。然后修改 `site.config.yaml`,再写第一篇 Markdown。公开包名是 `hd-garden`;装进站点之后也可以用 `garden`。不要运行 `npx garden init`,npm 上的 `garden` 是别人的样式库。
|
|
16
16
|
|
|
17
|
+
在国内想用 npm 镜像(例如 npmmirror),把 `registry` 写在自己的 `~/.npmrc` 或不提交的项目 `.npmrc` 里,不要让镜像地址进 `package-lock.json`:GitHub Actions 连不上镜像时 `npm ci` 会一直卡住。镜像会把新装包的 `resolved` 写成镜像地址,提交前运行 `node node_modules/hd-garden/scripts/check-lockfile-registry.mjs` 检查(Garden 仓库的 CI 也会检查);报错时把这些地址的域名换回 `https://registry.npmjs.org/`,路径和 integrity 都不用改。
|
|
18
|
+
|
|
17
19
|
也可以继续用 **Use this template**;该仓库同时是可运行的演示站。
|
|
18
20
|
|
|
19
21
|
完成后运行:
|
package/app/about/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { GithubProfile } from "../components/GithubProfile";
|
|
|
4
4
|
import { PageIntro } from "../components/PageIntro";
|
|
5
5
|
import { siteConfig } from "../site.config";
|
|
6
6
|
import { siteHref } from "../lib/site-href";
|
|
7
|
+
import { pageAlternates } from "../lib/page-alternates";
|
|
7
8
|
|
|
8
9
|
const about = siteConfig.pages.about;
|
|
9
10
|
const heading = about.heading ?? "关于";
|
|
@@ -18,6 +19,7 @@ const cta = about.cta ?? { href: "/blog", label: "浏览博文" };
|
|
|
18
19
|
export const metadata = {
|
|
19
20
|
title: { absolute: siteConfig.name },
|
|
20
21
|
description: about.subtitle || siteConfig.description,
|
|
22
|
+
alternates: pageAlternates("/about"),
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
export default function AboutPage() {
|
|
@@ -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 {
|
|
@@ -47,7 +48,7 @@ export async function generateMetadata({
|
|
|
47
48
|
return {
|
|
48
49
|
title: entry.title.replace(/^\d+\s*·\s*/u, ""),
|
|
49
50
|
description: entry.description,
|
|
50
|
-
alternates: { canonical: pathname },
|
|
51
|
+
alternates: { canonical: pathname, types: FEED_ALTERNATES },
|
|
51
52
|
openGraph: {
|
|
52
53
|
type: "article",
|
|
53
54
|
url: absoluteUrl(pathname, baseUrl),
|
package/app/columns/page.tsx
CHANGED
|
@@ -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) {
|
|
@@ -14,8 +14,8 @@ 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";
|
|
@@ -33,6 +33,7 @@ import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
|
33
33
|
import { linkHost } from "../../scripts/link-meta-core.mjs";
|
|
34
34
|
import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
35
35
|
import { siteHref } from "../lib/site-href";
|
|
36
|
+
import { codeLanguageLabel, fenceLanguage } from "../../ui/garden/code-language.mjs";
|
|
36
37
|
|
|
37
38
|
// Full URLs back to this site are internal links, written as root-relative paths.
|
|
38
39
|
const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
|
|
@@ -271,7 +272,8 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
271
272
|
rehypeRaw,
|
|
272
273
|
rehypeKatex,
|
|
273
274
|
rehypeSlug,
|
|
274
|
-
|
|
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"] }],
|
|
275
277
|
rehypeHighlight,
|
|
276
278
|
]}
|
|
277
279
|
components={{
|
|
@@ -331,7 +333,7 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
331
333
|
const className = isValidElement<{ className?: string }>(child)
|
|
332
334
|
? child.props.className
|
|
333
335
|
: undefined;
|
|
334
|
-
const language = className
|
|
336
|
+
const language = fenceLanguage(className);
|
|
335
337
|
const preProps = { ...props };
|
|
336
338
|
delete preProps.node;
|
|
337
339
|
|
|
@@ -341,7 +343,7 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
341
343
|
|
|
342
344
|
return (
|
|
343
345
|
<CodeCopy>
|
|
344
|
-
<pre className="code" {...preProps} data-language={language
|
|
346
|
+
<pre className="code" {...preProps} data-language={codeLanguageLabel(language) || undefined}>
|
|
345
347
|
{children}
|
|
346
348
|
</pre>
|
|
347
349
|
</CodeCopy>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useId, useRef, useState, type MouseEvent } from "react";
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState, type FocusEvent, type MouseEvent } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { previewBody, previewFrameProps } from "../../../ui/garden/file-block.mjs";
|
|
6
6
|
import { ActionGlyph, FileGlyph } from "./file-icons";
|
|
@@ -32,6 +32,7 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
|
|
|
32
32
|
const request = useRef<AbortController | null>(null);
|
|
33
33
|
const panel = useRef<HTMLElement>(null);
|
|
34
34
|
const closeButton = useRef<HTMLButtonElement>(null);
|
|
35
|
+
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
35
36
|
const titleId = useId();
|
|
36
37
|
|
|
37
38
|
const close = useCallback(() => {
|
|
@@ -75,7 +76,7 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
|
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
78
|
if (event.key !== "Tab" || !panel.current) return;
|
|
78
|
-
const focusable = [...panel.current.querySelectorAll<HTMLElement>("a[href], button, iframe, [tabindex]:not([tabindex='-1'])")];
|
|
79
|
+
const focusable = [...panel.current.querySelectorAll<HTMLElement>("a[href], button, iframe, [tabindex]:not([tabindex='-1'])")].filter((element) => !element.hasAttribute("data-gf-sentinel"));
|
|
79
80
|
if (!focusable.length) return;
|
|
80
81
|
const first = focusable[0];
|
|
81
82
|
const last = focusable[focusable.length - 1];
|
|
@@ -94,6 +95,19 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
|
|
|
94
95
|
};
|
|
95
96
|
}, [open, close]);
|
|
96
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
|
+
|
|
97
111
|
// A plain click opens the drawer; a modified click (new tab, new window) keeps the link.
|
|
98
112
|
const openDrawer = (event: MouseEvent<HTMLAnchorElement>) => {
|
|
99
113
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
@@ -145,9 +159,13 @@ export function FilePreview({ file, compact }: { file: PreviewFile; compact: boo
|
|
|
145
159
|
</div>
|
|
146
160
|
) : (
|
|
147
161
|
<div className="gf-db gf-db-frame">
|
|
148
|
-
|
|
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} />
|
|
149
166
|
</div>
|
|
150
167
|
)}
|
|
168
|
+
<p className="gf-hint">Esc 关闭</p>
|
|
151
169
|
</aside>
|
|
152
170
|
</div>,
|
|
153
171
|
document.body,
|
package/app/garden/page.tsx
CHANGED
|
@@ -4,10 +4,12 @@ import { GardenContentGuide } from "../components/GardenContentGuide";
|
|
|
4
4
|
import { GardenMediaGuide } from "../components/GardenMediaGuide";
|
|
5
5
|
import { PageIntro } from "../components/PageIntro";
|
|
6
6
|
import { siteConfig } from "../site.config";
|
|
7
|
+
import { pageAlternates } from "../lib/page-alternates";
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
9
10
|
title: "Garden",
|
|
10
11
|
description: "用 Markdown 写文章。推送之后,页面、搜索和 RSS 一起更新。",
|
|
12
|
+
alternates: pageAlternates("/garden"),
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
const jumps = [
|
package/app/globals.css
CHANGED
package/app/layout.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { githubUrl, siteConfig } from "./site.config";
|
|
14
14
|
import { brand } from "@garden/site-config";
|
|
15
15
|
import { HASH_JUMP_SCRIPT } from "../ui/garden/hash-jump.mjs";
|
|
16
|
+
import { FEED_ALTERNATES } from "./lib/page-alternates";
|
|
16
17
|
|
|
17
18
|
export async function generateMetadata(): Promise<Metadata> {
|
|
18
19
|
const siteUrl = await getSiteUrl();
|
|
@@ -25,21 +26,16 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
25
26
|
},
|
|
26
27
|
description: siteDescription,
|
|
27
28
|
authors: [{ name: siteConfig.author.name, url: githubUrl }],
|
|
28
|
-
alternates: {
|
|
29
|
-
types: {
|
|
30
|
-
"application/rss+xml": "/rss.xml",
|
|
31
|
-
},
|
|
32
|
-
},
|
|
29
|
+
alternates: { types: FEED_ALTERNATES },
|
|
33
30
|
icons: {
|
|
34
31
|
icon: [{ url: brand.favicon, type: brand.favicon.endsWith(".svg") ? "image/svg+xml" : undefined }],
|
|
35
32
|
...(brand.appleTouchIcon ? { apple: [{ url: brand.appleTouchIcon, sizes: "180x180" }] } : {}),
|
|
36
33
|
},
|
|
34
|
+
// index and follow are the default, so no robots meta is written here: the
|
|
35
|
+
// 404 page keeps exactly one, the noindex vinext adds, and noindex pages set
|
|
36
|
+
// their own. Only Google's preview limits are stated.
|
|
37
37
|
robots: {
|
|
38
|
-
index: true,
|
|
39
|
-
follow: true,
|
|
40
38
|
googleBot: {
|
|
41
|
-
index: true,
|
|
42
|
-
follow: true,
|
|
43
39
|
"max-image-preview": "large",
|
|
44
40
|
"max-snippet": -1,
|
|
45
41
|
"max-video-preview": -1,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { siteHref } from "./site-href";
|
|
2
|
+
|
|
3
|
+
/** The site feed, linked from every page's head. */
|
|
4
|
+
export const FEED_ALTERNATES = { "application/rss+xml": "/rss.xml" } as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Head links for a page that should be indexed under one address: the canonical
|
|
8
|
+
* URL, shaped like the site's links (trailing slash in a static export) and
|
|
9
|
+
* resolved against metadataBase, plus the feed. A page that sets `alternates`
|
|
10
|
+
* replaces the layout's, so the feed link is repeated here.
|
|
11
|
+
*/
|
|
12
|
+
export function pageAlternates(pathname: string) {
|
|
13
|
+
return { canonical: siteHref(pathname), types: FEED_ALTERNATES };
|
|
14
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -4,10 +4,12 @@ import { homeListDate } from "./lib/a2-meta";
|
|
|
4
4
|
import { columns, formatDate, postHref, posts } from "./lib/content";
|
|
5
5
|
import { githubUrl, siteConfig } from "./site.config";
|
|
6
6
|
import { siteHref } from "./lib/site-href";
|
|
7
|
+
import { pageAlternates } from "./lib/page-alternates";
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
9
10
|
title: { absolute: siteConfig.name },
|
|
10
11
|
description: siteConfig.description,
|
|
12
|
+
alternates: pageAlternates("/"),
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
function day(value: string) {
|
package/app/styles/a2.css
CHANGED
|
@@ -386,8 +386,7 @@ html[data-theme="dark"] .column-reading-page .e-content {
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
/* A heading's own anchor link reads as the heading: no underline in any
|
|
389
|
-
state.
|
|
390
|
-
keep their own underline. */
|
|
389
|
+
state. Links inside the heading (a.ext) keep their own underline. */
|
|
391
390
|
.markdown-body .h-anchor,
|
|
392
391
|
.markdown-body .h-anchor:hover,
|
|
393
392
|
.markdown-body .h-anchor:active {
|
|
@@ -395,12 +394,35 @@ html[data-theme="dark"] .column-reading-page .e-content {
|
|
|
395
394
|
text-decoration: none;
|
|
396
395
|
}
|
|
397
396
|
|
|
397
|
+
/* Keyboard focus rings the heading, not the anchor, so a heading split
|
|
398
|
+
around a link shows one ring. */
|
|
398
399
|
.markdown-body .h-anchor:focus-visible {
|
|
400
|
+
outline: none;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.markdown-body :is(h2, h3, h4):has(.h-anchor:focus-visible) {
|
|
399
404
|
outline: 2px solid var(--blue);
|
|
400
405
|
outline-offset: 2px;
|
|
401
406
|
border-radius: 2px;
|
|
402
407
|
}
|
|
403
408
|
|
|
409
|
+
/* A heading with a link in it has one self-link (its first stretch of text),
|
|
410
|
+
stretched over the whole heading; the link inside sits above it. */
|
|
411
|
+
.markdown-body .h-split {
|
|
412
|
+
position: relative;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.markdown-body .h-split .h-anchor::after {
|
|
416
|
+
content: "";
|
|
417
|
+
position: absolute;
|
|
418
|
+
inset: 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.markdown-body .h-split a:not(.h-anchor) {
|
|
422
|
+
position: relative;
|
|
423
|
+
z-index: 1;
|
|
424
|
+
}
|
|
425
|
+
|
|
404
426
|
.markdown-body a.ext {
|
|
405
427
|
text-decoration-style: dotted;
|
|
406
428
|
text-decoration-thickness: 1.5px;
|
|
@@ -1233,6 +1255,26 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
|
|
|
1233
1255
|
transform: translateY(-50%);
|
|
1234
1256
|
}
|
|
1235
1257
|
|
|
1258
|
+
/* The language label sits left of the copy button, 8px apart, centred on it. */
|
|
1259
|
+
.markdown-body .ux-cb > pre[data-language]::before {
|
|
1260
|
+
top: 8px;
|
|
1261
|
+
right: 48px;
|
|
1262
|
+
line-height: 32px;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/* 「已复制」 widens the button over the label; the label steps aside meanwhile. */
|
|
1266
|
+
.markdown-body .ux-cb:has(> .ux-done) > pre[data-language]::before {
|
|
1267
|
+
visibility: hidden;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/* Narrow screens: no language label, in the article (only the copy button
|
|
1271
|
+
stays) and in the file preview drawer alike; nothing else changes. */
|
|
1272
|
+
@media (max-width: 639.98px) {
|
|
1273
|
+
.markdown-body pre[data-language]::before {
|
|
1274
|
+
display: none;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1236
1278
|
@media (hover: hover) {
|
|
1237
1279
|
.ux-copy {
|
|
1238
1280
|
opacity: 0;
|
|
@@ -1599,6 +1641,11 @@ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
|
|
|
1599
1641
|
.gf-db-frame { padding: 0; overflow: hidden; background: var(--card); }
|
|
1600
1642
|
.gf-frame { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
1601
1643
|
.gf-drawer[data-preview="pdf"] .gf-frame { background: var(--paper-deep); }
|
|
1644
|
+
/* Focus catchers around the iframe: reachable by Tab, never seen. */
|
|
1645
|
+
.gf-sentinel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
1646
|
+
/* A quiet reminder; Esc pressed inside an iframe cannot reach the page. Phones have no Esc. */
|
|
1647
|
+
.gf-hint { flex: none; margin: 0; padding: 6px 20px 8px; font-size: 12px; line-height: 1.5; color: var(--muted); }
|
|
1648
|
+
@media (hover: none) and (pointer: coarse) { .gf-hint { display: none; } }
|
|
1602
1649
|
.gf-db .gf-doc { max-width: none; margin: 0; font-size: 15px; line-height: 1.75; }
|
|
1603
1650
|
.gf-db .gf-doc > :first-child { margin-top: 0; }
|
|
1604
1651
|
@keyframes gf-fade { from { opacity: 0; } }
|
package/app/tags/page.tsx
CHANGED
|
@@ -5,10 +5,12 @@ import { siteHref } from "../lib/site-href";
|
|
|
5
5
|
import { articleTagHref, tagCounts } from "../lib/tag-filter";
|
|
6
6
|
import { TagRedirect } from "../components/TagRedirect";
|
|
7
7
|
import { siteDocumentTitle } from "../site.config";
|
|
8
|
+
import { pageAlternates } from "../lib/page-alternates";
|
|
8
9
|
|
|
9
10
|
export const metadata = {
|
|
10
11
|
title: siteDocumentTitle,
|
|
11
12
|
description: "全部标签。",
|
|
13
|
+
alternates: pageAlternates("/tags"),
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
export default function TagsPage() {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import type { Metadata } from "next";
|
|
3
|
+
import { FEED_ALTERNATES } from "../../lib/page-alternates";
|
|
3
4
|
import { notFound } from "next/navigation";
|
|
4
5
|
import { ArrowLeft } from "@phosphor-icons/react/ssr";
|
|
5
6
|
import { Comments } from "../../components/Comments";
|
|
@@ -34,7 +35,7 @@ export async function generateMetadata({
|
|
|
34
35
|
return {
|
|
35
36
|
title: thought.title,
|
|
36
37
|
description: thought.content.replace(/\s+/gu, " ").slice(0, 160),
|
|
37
|
-
alternates: { canonical: pathname },
|
|
38
|
+
alternates: { canonical: pathname, types: FEED_ALTERNATES },
|
|
38
39
|
openGraph: {
|
|
39
40
|
type: "article",
|
|
40
41
|
url: absoluteUrl(pathname, baseUrl),
|
package/app/thoughts/page.tsx
CHANGED
|
@@ -3,10 +3,12 @@ import { ThoughtCard } from "../components/ThoughtCard";
|
|
|
3
3
|
import { ContentEmptyState } from "../components/ContentEmptyState";
|
|
4
4
|
import { PageIntro } from "../components/PageIntro";
|
|
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.thoughts.subtitle || siteConfig.description,
|
|
11
|
+
alternates: pageAlternates("/thoughts"),
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export default function ThoughtsPage() {
|
package/app/ui-parity/page.tsx
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import Image from "next/image";
|
|
2
2
|
import { ParityInteractions } from "../../ui/react/parity";
|
|
3
3
|
|
|
4
|
+
// Internal comparison page (docs/intelligent-ui-parity.md): Garden's renders next
|
|
5
|
+
// to the ChatGPT originals. Kept out of search, so it has no canonical address.
|
|
6
|
+
export const metadata = { robots: { index: false, follow: false } };
|
|
7
|
+
|
|
4
8
|
const slices = [
|
|
5
9
|
{ label: "商品销售", value: 78.2, color: "#539af8" },
|
|
6
10
|
{ label: "平台与营销服务", value: 9.4, color: "#6bc67f" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hd-garden",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
|
|
6
6
|
"license": "MIT",
|
|
@@ -97,7 +97,6 @@
|
|
|
97
97
|
"react-dom": "19.3.0",
|
|
98
98
|
"react-markdown": "^10.1.0",
|
|
99
99
|
"react-server-dom-webpack": "19.3.0",
|
|
100
|
-
"rehype-autolink-headings": "^7.1.0",
|
|
101
100
|
"rehype-highlight": "^7.0.2",
|
|
102
101
|
"rehype-katex": "^7.0.1",
|
|
103
102
|
"rehype-raw": "^7.0.0",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails when package-lock.json downloads anything from somewhere other than the
|
|
3
|
+
// public npm registry. A mirror (for example a China mirror) belongs in your own
|
|
4
|
+
// ~/.npmrc or a project .npmrc that you keep out of git, never in the lockfile:
|
|
5
|
+
// CI runners elsewhere may not reach it, and `npm ci` then hangs without output.
|
|
6
|
+
// Usage: node scripts/check-lockfile-registry.mjs [path/to/package-lock.json]
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
export const ALLOWED_REGISTRY = "https://registry.npmjs.org/";
|
|
11
|
+
|
|
12
|
+
/** Entries of a lockfile whose `resolved` URL is not on registry.npmjs.org. */
|
|
13
|
+
export function foreignResolved(lock) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const [name, entry] of Object.entries(lock?.packages ?? {})) {
|
|
16
|
+
const resolved = entry?.resolved;
|
|
17
|
+
// Workspace links and the root package have no download URL.
|
|
18
|
+
if (typeof resolved !== "string" || entry.link) continue;
|
|
19
|
+
if (!resolved.startsWith(ALLOWED_REGISTRY)) out.push({ name: name || "(root)", resolved });
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
25
|
+
const file = process.argv[2] ?? "package-lock.json";
|
|
26
|
+
const offenders = foreignResolved(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
27
|
+
if (offenders.length) {
|
|
28
|
+
console.error(`${file}: ${offenders.length} 个包的 resolved 不在 ${ALLOWED_REGISTRY}:`);
|
|
29
|
+
for (const { name, resolved } of offenders.slice(0, 20)) console.error(` ${name} ${resolved}`);
|
|
30
|
+
if (offenders.length > 20) console.error(` …… 另外 ${offenders.length - 20} 个`);
|
|
31
|
+
console.error("镜像请写在自己的 .npmrc 里,不要进锁文件。把这些地址改回 registry.npmjs.org 后再提交。");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
console.log(`${file}: 全部 resolved 都在 ${ALLOWED_REGISTRY}`);
|
|
35
|
+
}
|
package/scripts/file-preview.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* removed here: script-like elements, on* attributes, javascript: and other
|
|
13
13
|
* non-http URLs (images may still use data:).
|
|
14
14
|
*/
|
|
15
|
+
import fs from "node:fs";
|
|
15
16
|
import rehypeHighlight from "rehype-highlight";
|
|
16
17
|
import rehypeKatex from "rehype-katex";
|
|
17
18
|
import rehypeRaw from "rehype-raw";
|
|
@@ -22,6 +23,7 @@ import remarkRehype from "remark-rehype";
|
|
|
22
23
|
import { unified } from "unified";
|
|
23
24
|
import { visit } from "unist-util-visit";
|
|
24
25
|
import { markdownSyntaxPlugins } from "../ui/garden/markdown-syntax.mjs";
|
|
26
|
+
import { codeLanguageLabel, fenceLanguage } from "../ui/garden/code-language.mjs";
|
|
25
27
|
|
|
26
28
|
const DROPPED_ELEMENTS = new Set(["script", "noscript", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "link", "meta", "form", "template"]);
|
|
27
29
|
const URL_PROPERTIES = new Set(["href", "src", "action", "formAction", "poster", "cite", "background", "xLinkHref"]);
|
|
@@ -80,9 +82,9 @@ export function rehypePreviewShape() {
|
|
|
80
82
|
}
|
|
81
83
|
if (node.tagName === "pre") {
|
|
82
84
|
const code = node.children.find((child) => child.type === "element" && child.tagName === "code");
|
|
83
|
-
const language = [].concat(code?.properties?.className ?? [])
|
|
85
|
+
const language = fenceLanguage([].concat(code?.properties?.className ?? []));
|
|
84
86
|
node.properties.className = [...[].concat(node.properties.className ?? []), "code"];
|
|
85
|
-
if (language) node.properties.dataLanguage = language
|
|
87
|
+
if (codeLanguageLabel(language)) node.properties.dataLanguage = codeLanguageLabel(language);
|
|
86
88
|
}
|
|
87
89
|
if (node.tagName === "a" && node.properties?.target === "_blank") {
|
|
88
90
|
node.properties.className = [...[].concat(node.properties.className ?? []), "ext"];
|
|
@@ -112,6 +114,88 @@ export function renderFilePreview(markdown, { idPrefix = "gf-" } = {}) {
|
|
|
112
114
|
|
|
113
115
|
const escapeText = (value) => String(value).replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The article's colours and code type (app/styles/tokens.css, the code rules in
|
|
119
|
+
* app/globals.css and a2.css), so a reader without JavaScript sees code as on
|
|
120
|
+
* the article page. The page has no theme switch: dark follows the system.
|
|
121
|
+
* tests/garden-file.test.mjs checks these against the stylesheets.
|
|
122
|
+
*/
|
|
123
|
+
export const PREVIEW_TOKENS = {
|
|
124
|
+
mono: '"JetBrains Mono", "SF Mono", ui-monospace, Menlo, Consolas, "Noto Sans Mono CJK SC", monospace',
|
|
125
|
+
sans: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif',
|
|
126
|
+
light: { paper: "#faf9f5", ink: "#1b1a18", well: "#f0eee6", tint: "rgb(27 26 24 / 5.5%)", rule: "#e6e3dc" },
|
|
127
|
+
dark: { paper: "#1f1e1d", ink: "#eeece7", well: "#262624", tint: "rgb(238 236 231 / 7%)", rule: "#3d3d3a" },
|
|
128
|
+
// .markdown-body pre in globals.css: the block's own type, under the <code> that holds the text.
|
|
129
|
+
pre: { fontSize: "12px", lineHeight: "1.7", color: "#e6e6e2" },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Highlight colours are not copied by hand: every .hljs-* rule of the article's
|
|
133
|
+
// stylesheets, in the order the site loads them.
|
|
134
|
+
const STYLESHEETS = ["../app/globals.css", "../app/styles/a2.css"];
|
|
135
|
+
const DARK_PREFIX = /^(?::root|html)\[data-theme="dark"\]\s+/u;
|
|
136
|
+
const HLJS_SELECTOR = /^\.hljs-[\w-]+(?:\.[\w-]+)*(?:\s+\.hljs-[\w-]+)?$/u;
|
|
137
|
+
|
|
138
|
+
/** The .hljs-* rules of some CSS, split into light (always) and dark (html[data-theme="dark"]). */
|
|
139
|
+
export function highlightRules(css) {
|
|
140
|
+
const light = [];
|
|
141
|
+
const dark = [];
|
|
142
|
+
const text = css.replace(/\/\*[\s\S]*?\*\//gu, "");
|
|
143
|
+
let depth = 0;
|
|
144
|
+
let start = 0;
|
|
145
|
+
// Top-level rules only: nothing inside @media or other blocks.
|
|
146
|
+
for (let index = 0; index < text.length; index++) {
|
|
147
|
+
const char = text[index];
|
|
148
|
+
if (char === "{") {
|
|
149
|
+
if (depth === 0) {
|
|
150
|
+
const selectorText = text.slice(start, index).trim();
|
|
151
|
+
const end = text.indexOf("}", index);
|
|
152
|
+
const body = text.slice(index + 1, end);
|
|
153
|
+
if (!selectorText.startsWith("@") && !body.includes("{")) {
|
|
154
|
+
const declarations = body.split(";").map((part) => part.trim().replace(/\s*:\s*/u, ":")).filter(Boolean).join(";");
|
|
155
|
+
const selectors = selectorText.split(",").map((part) => part.trim());
|
|
156
|
+
const lightSelectors = selectors.filter((selector) => HLJS_SELECTOR.test(selector));
|
|
157
|
+
const darkSelectors = selectors.filter((selector) => DARK_PREFIX.test(selector)).map((selector) => selector.replace(DARK_PREFIX, "")).filter((selector) => HLJS_SELECTOR.test(selector));
|
|
158
|
+
if (lightSelectors.length) light.push(`${lightSelectors.join(",")}{${declarations}}`);
|
|
159
|
+
// html:root keeps the weight of html[data-theme="dark"], so the dark rules win exactly where they do on the site.
|
|
160
|
+
if (darkSelectors.length) dark.push(`${[...new Set(darkSelectors)].map((selector) => `html:root ${selector}`).join(",")}{${declarations}}`);
|
|
161
|
+
start = end + 1;
|
|
162
|
+
index = end;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
depth++;
|
|
167
|
+
} else if (char === "}") {
|
|
168
|
+
depth--;
|
|
169
|
+
if (depth === 0) start = index + 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { light, dark };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let previewStyle;
|
|
176
|
+
/** The standalone page's stylesheet, built once per build. */
|
|
177
|
+
export function previewStyleSheet() {
|
|
178
|
+
if (previewStyle) return previewStyle;
|
|
179
|
+
const rules = STYLESHEETS.map((file) => highlightRules(fs.readFileSync(new URL(file, import.meta.url), "utf8")));
|
|
180
|
+
const { mono, sans, light, dark, pre } = PREVIEW_TOKENS;
|
|
181
|
+
const theme = (colors) => `body{background:${colors.paper};color:${colors.ink}}pre{background:${colors.well}}pre code{color:${colors.ink}}:not(pre)>code{background:${colors.tint};color:${colors.ink}}th,td{border-color:${colors.rule}}`;
|
|
182
|
+
previewStyle = [
|
|
183
|
+
`body{margin:0;font:16px/1.75 ${sans}}`,
|
|
184
|
+
"main{max-width:720px;margin:0 auto;padding:32px 20px 64px}",
|
|
185
|
+
"img{max-width:100%;height:auto}",
|
|
186
|
+
`pre,code{font-family:${mono}}`,
|
|
187
|
+
`pre{margin:28px 0;padding:18px 22px;border-radius:8px;overflow-x:auto;font-size:${pre.fontSize};line-height:${pre.lineHeight};color:${pre.color}}`,
|
|
188
|
+
"pre code{display:block;background:none;padding:0;font-size:14px;line-height:1.6}",
|
|
189
|
+
`:not(pre)>code{font:400 14px/1 ${mono};padding:3px 6px;border-radius:5px}`,
|
|
190
|
+
".table-wrap{overflow-x:auto}table{border-collapse:collapse}th,td{border:1px solid;padding:6px 10px}",
|
|
191
|
+
"a{color:inherit}.katex-html{display:none}",
|
|
192
|
+
theme(light),
|
|
193
|
+
...rules.flatMap((sheet) => sheet.light),
|
|
194
|
+
`@media (prefers-color-scheme:dark){${theme(dark)}${rules.flatMap((sheet) => sheet.dark).join("")}}`,
|
|
195
|
+
].join("");
|
|
196
|
+
return previewStyle;
|
|
197
|
+
}
|
|
198
|
+
|
|
115
199
|
/**
|
|
116
200
|
* The published preview: a small standalone page, so a reader without
|
|
117
201
|
* JavaScript who follows the 预览 link gets something readable. The drawer
|
|
@@ -125,7 +209,7 @@ export function previewDocument(html, { title = "预览" } = {}) {
|
|
|
125
209
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
126
210
|
<meta name="robots" content="noindex">
|
|
127
211
|
<title>${escapeText(title)}</title>
|
|
128
|
-
<style
|
|
212
|
+
<style>${previewStyleSheet()}</style>
|
|
129
213
|
</head>
|
|
130
214
|
<body><main class="gf-preview-doc">
|
|
131
215
|
${html}
|