hd-garden 0.5.15 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 } 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={{
@@ -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/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,11 +26,7 @@ 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" }] } : {}),
@@ -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,25 @@ 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: only the copy button; the code block layout stays as it is. */
1271
+ @media (max-width: 639.98px) {
1272
+ .markdown-body .ux-cb > pre[data-language]::before {
1273
+ display: none;
1274
+ }
1275
+ }
1276
+
1236
1277
  @media (hover: hover) {
1237
1278
  .ux-copy {
1238
1279
  opacity: 0;
@@ -1599,6 +1640,11 @@ html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
1599
1640
  .gf-db-frame { padding: 0; overflow: hidden; background: var(--card); }
1600
1641
  .gf-frame { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
1601
1642
  .gf-drawer[data-preview="pdf"] .gf-frame { background: var(--paper-deep); }
1643
+ /* Focus catchers around the iframe: reachable by Tab, never seen. */
1644
+ .gf-sentinel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
1645
+ /* A quiet reminder; Esc pressed inside an iframe cannot reach the page. Phones have no Esc. */
1646
+ .gf-hint { flex: none; margin: 0; padding: 6px 20px 8px; font-size: 12px; line-height: 1.5; color: var(--muted); }
1647
+ @media (hover: none) and (pointer: coarse) { .gf-hint { display: none; } }
1602
1648
  .gf-db .gf-doc { max-width: none; margin: 0; font-size: 15px; line-height: 1.75; }
1603
1649
  .gf-db .gf-doc > :first-child { margin-top: 0; }
1604
1650
  @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.16",
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",
@@ -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 } 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"]);
@@ -82,7 +84,7 @@ export function rehypePreviewShape() {
82
84
  const code = node.children.find((child) => child.type === "element" && child.tagName === "code");
83
85
  const language = [].concat(code?.properties?.className ?? []).map(String).find((name) => name.startsWith("language-"))?.slice(9);
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}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The label a code block shows for its fence language. The article, the preview
3
+ * drawer and the standalone preview page all go through here, so ```py and
4
+ * ```python read the same everywhere. Common aliases map to one language; JS and
5
+ * TS use their abbreviations, every other language its full name in capitals
6
+ * (PYTHON, GO, RUST, BASH), and an unknown fence language is shown in capitals.
7
+ */
8
+ const ALIASES = {
9
+ py: "python",
10
+ python3: "python",
11
+ js: "javascript",
12
+ mjs: "javascript",
13
+ cjs: "javascript",
14
+ jsx: "jsx",
15
+ ts: "typescript",
16
+ mts: "typescript",
17
+ tsx: "tsx",
18
+ sh: "bash",
19
+ zsh: "bash",
20
+ shell: "shell",
21
+ console: "shell",
22
+ golang: "go",
23
+ rs: "rust",
24
+ rb: "ruby",
25
+ kt: "kotlin",
26
+ yml: "yaml",
27
+ md: "markdown",
28
+ htm: "html",
29
+ "c++": "cpp",
30
+ cc: "cpp",
31
+ hpp: "cpp",
32
+ cs: "csharp",
33
+ docker: "dockerfile",
34
+ ps1: "powershell",
35
+ plaintext: "text",
36
+ txt: "text",
37
+ };
38
+
39
+ /** JavaScript and TypeScript go by their common abbreviations; everything else by its full name. */
40
+ const NAMES = { javascript: "JS", typescript: "TS", cpp: "C++", csharp: "C#" };
41
+
42
+ /** Display label for a fence language, or "" when there is none. */
43
+ export function codeLanguageLabel(language) {
44
+ const raw = String(language ?? "").trim().toLowerCase();
45
+ if (!raw) return "";
46
+ const name = ALIASES[raw] ?? raw;
47
+ return NAMES[name] ?? name.toUpperCase();
48
+ }
@@ -29,17 +29,17 @@ export const FILE_KEYS = ["src", "title", "note", "files"];
29
29
  export const FILE_ITEM_KEYS = ["src", "title", "note"];
30
30
 
31
31
  const FORMATS = {
32
- md: { label: "Markdown", preview: "md", icon: "doc" },
33
- markdown: { label: "Markdown", preview: "md", icon: "doc" },
34
- html: { label: "HTML", preview: "html", icon: "code" },
35
- htm: { label: "HTML", preview: "html", icon: "code" },
36
- pdf: { label: "PDF", preview: "pdf", icon: "doc" },
37
- zip: { label: "ZIP", preview: "", icon: "archive" },
38
- gz: { label: "GZ", preview: "", icon: "archive" },
39
- tgz: { label: "TGZ", preview: "", icon: "archive" },
40
- "7z": { label: "7Z", preview: "", icon: "archive" },
41
- rar: { label: "RAR", preview: "", icon: "archive" },
42
- tar: { label: "TAR", preview: "", icon: "archive" },
32
+ md: { preview: "md", icon: "doc" },
33
+ markdown: { preview: "md", icon: "doc" },
34
+ html: { preview: "html", icon: "code" },
35
+ htm: { preview: "html", icon: "code" },
36
+ pdf: { preview: "pdf", icon: "doc" },
37
+ zip: { preview: "", icon: "archive" },
38
+ gz: { preview: "", icon: "archive" },
39
+ tgz: { preview: "", icon: "archive" },
40
+ "7z": { preview: "", icon: "archive" },
41
+ rar: { preview: "", icon: "archive" },
42
+ tar: { preview: "", icon: "archive" },
43
43
  };
44
44
 
45
45
  /**
@@ -69,10 +69,10 @@ export function fileName(src) {
69
69
  }
70
70
  }
71
71
 
72
- /** Format label: Markdown, HTML, PDF, ZIP, or the extension in capitals. */
72
+ /** Format label: the extension in capitals (MD, HTML, PDF, ZIP, CSV …), or 文件 when there is none. */
73
73
  export function formatLabel(src) {
74
74
  const ext = fileExtension(src);
75
- return FORMATS[ext]?.label ?? (ext ? ext.toUpperCase() : "文件");
75
+ return ext ? ext.toUpperCase() : "文件";
76
76
  }
77
77
 
78
78
  /** Icon family for the card: doc, code, archive or file. */
@@ -133,7 +133,7 @@ export function formatSize(bytes) {
133
133
  return `${text} ${units[unit]}`;
134
134
  }
135
135
 
136
- /** 「Markdown · 12 KB」, or just the format when the size is unknown. */
136
+ /** 「MD · 12 KB」, or just the format when the size is unknown. */
137
137
  export function fileMeta(file) {
138
138
  const size = formatSize(file?.size);
139
139
  return [file?.format, size].filter(Boolean).join(" · ");