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 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
  完成后运行:
@@ -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),
@@ -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
- [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"] }],
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?.match(/language-([\w-]+)/)?.[1];
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?.toUpperCase()}>
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
- <iframe className="gf-frame" {...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} />
149
166
  </div>
150
167
  )}
168
+ <p className="gf-hint">Esc 关闭</p>
151
169
  </aside>
152
170
  </div>,
153
171
  document.body,
@@ -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
@@ -2188,7 +2188,7 @@ button {
2188
2188
  position: absolute;
2189
2189
  top: 8px;
2190
2190
  right: 12px;
2191
- color: #8b929c;
2191
+ color: var(--muted);
2192
2192
  font-size: 9px;
2193
2193
  font-weight: 700;
2194
2194
  letter-spacing: 0.12em;
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. Keyboard focus keeps the ring. Links inside the heading (a.ext)
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),
@@ -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() {
@@ -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.15",
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
+ }
@@ -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 ?? []).map(String).find((name) => name.startsWith("language-"))?.slice(9);
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.toUpperCase();
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, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
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>body{margin:0;background:#fbfaf7;color:#1f1e1c;font:16px/1.75 -apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans SC",sans-serif}main{max-width:720px;margin:0 auto;padding:32px 20px 64px}img{max-width:100%;height:auto}pre{overflow:auto;padding:12px 14px;background:#f2f0ea;border-radius:8px}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}.table-wrap{overflow-x:auto}table{border-collapse:collapse}th,td{border:1px solid #ddd8cc;padding:6px 10px}a{color:inherit}.katex-html{display:none}@media (prefers-color-scheme:dark){body{background:#191918;color:#e9e6df}pre{background:#252422}th,td{border-color:#3a3833}}</style>
212
+ <style>${previewStyleSheet()}</style>
129
213
  </head>
130
214
  <body><main class="gf-preview-doc">
131
215
  ${html}