hd-garden 0.5.17 → 0.6.0

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.
@@ -8,9 +8,10 @@ import { a2WordCounts } from "@garden/generated-content";
8
8
  import { demoDateLabel, demoDateTime } from "../../lib/a2-meta";
9
9
  import { extractHeadings } from "../../lib/content";
10
10
  import { a2Slugs, readA2Article } from "../../lib/a2-preview";
11
+ import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
11
12
 
12
13
  export function generateStaticParams() {
13
- return a2Slugs().map((slug) => ({ slug }));
14
+ return staticParams(a2Slugs().map((slug) => ({ slug })), { slug: STATIC_PLACEHOLDER });
14
15
  }
15
16
 
16
17
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
@@ -23,7 +24,10 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
23
24
  export default async function A2ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
24
25
  const { slug } = await params;
25
26
  const article = readA2Article(slug);
26
- if (!article) notFound();
27
+ if (!article) {
28
+ if (isStaticPlaceholder(slug)) return null;
29
+ notFound();
30
+ }
27
31
  const headings = extractHeadings(article.content);
28
32
  const wordCount = a2WordCounts[slug] ?? 0;
29
33
  return (
@@ -7,6 +7,8 @@ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } fro
7
7
  import { MarkdownArticle } from "../../../components/MarkdownArticle";
8
8
  import { repositoryContentPath } from "../../../lib/repository-path.mjs";
9
9
  import { readMarkdown, readText } from "../../../lib/ui-content";
10
+ import { formatInlineLink, replaceInlineLinks } from "../../../../ui/garden/markdown-destination.mjs";
11
+ import { STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
10
12
 
11
13
  export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
12
14
  export const dynamicParams = false;
@@ -14,7 +16,7 @@ export const metadata = { title: "草稿预览", robots: { index: false, follow:
14
16
 
15
17
  /** Draft preview is served by the admin worker. The public static export has no paths here. */
16
18
  export function generateStaticParams() {
17
- return [];
19
+ return staticParams([], { path: [STATIC_PLACEHOLDER] });
18
20
  }
19
21
 
20
22
  const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(value);
@@ -22,8 +24,8 @@ const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(val
22
24
  /** Same-name-directory files go through the authenticated, sandboxed proxy. */
23
25
  function proxyAssets(content: string, post: string) {
24
26
  const proxy = (relative: string) => `/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(relative)}`;
25
- let output = content.replace(/(!\[[^\]]*\]\()<?([^)\s>]+)>?/gu, (match, prefix: string, src: string) =>
26
- isRelative(src) ? `${prefix}${proxy(src)}` : match,
27
+ let output = replaceInlineLinks(content, (link) =>
28
+ link.image && isRelative(link.url) ? formatInlineLink(link, proxy(link.url)) : undefined,
27
29
  );
28
30
  for (const fence of [...scanFences(output)].reverse()) {
29
31
  if (fence.lang !== "widget") continue;
@@ -25,9 +25,10 @@ import {
25
25
  webmentionApiEndpoint,
26
26
  } from "../../lib/site";
27
27
  import { githubUrl, siteConfig } from "../../site.config";
28
+ import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
28
29
 
29
30
  export function generateStaticParams() {
30
- return posts.map((post) => ({ path: post.path.split("/") }));
31
+ return staticParams(posts.map((post) => ({ path: post.path.split("/") })), { path: [STATIC_PLACEHOLDER] });
31
32
  }
32
33
 
33
34
  export async function generateMetadata({
@@ -72,7 +73,10 @@ export default async function PostPage({
72
73
  }) {
73
74
  const { path } = await params;
74
75
  const post = findPost(path.join("/"));
75
- if (!post) notFound();
76
+ if (!post) {
77
+ if (isStaticPlaceholder(path)) return null;
78
+ notFound();
79
+ }
76
80
  const headings = extractHeadings(post.content);
77
81
  const baseUrl = await getSiteUrl();
78
82
  const pathname = postHref(post);
@@ -27,12 +27,16 @@ import {
27
27
  } from "../../../lib/site";
28
28
  import { githubUrl, siteConfig } from "../../../site.config";
29
29
  import { siteHref } from "../../../lib/site-href";
30
+ import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
30
31
 
31
32
  export function generateStaticParams() {
32
- return columns.map((entry) => ({
33
- column: entry.column,
34
- path: columnEntryPath(entry).split("/"),
35
- }));
33
+ return staticParams(
34
+ columns.map((entry) => ({
35
+ column: entry.column,
36
+ path: columnEntryPath(entry).split("/"),
37
+ })),
38
+ { column: STATIC_PLACEHOLDER, path: [STATIC_PLACEHOLDER] },
39
+ );
36
40
  }
37
41
 
38
42
  export async function generateMetadata({
@@ -77,7 +81,10 @@ export default async function ColumnEntryPage({
77
81
  }) {
78
82
  const { column, path } = await params;
79
83
  const entry = findColumnEntry(column, path.join("/"));
80
- if (!entry) notFound();
84
+ if (!entry) {
85
+ if (isStaticPlaceholder(column)) return null;
86
+ notFound();
87
+ }
81
88
  const series = columns.filter((item) => item.column === column);
82
89
  const listed = [...series].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
83
90
  const currentIndex = series.findIndex((item) => item.path === entry.path);
package/app/layout.tsx CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  } from "./lib/site";
13
13
  import { githubUrl, siteConfig } from "./site.config";
14
14
  import { brand } from "@garden/site-config";
15
- import { HASH_JUMP_SCRIPT } from "../ui/garden/hash-jump.mjs";
16
15
  import { FEED_ALTERNATES } from "./lib/page-alternates";
17
16
 
18
17
  export async function generateMetadata(): Promise<Metadata> {
@@ -84,8 +83,6 @@ export default function RootLayout({
84
83
  '(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||(!t&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light";}catch(e){}})();',
85
84
  }}
86
85
  />
87
- {/* Before the router's scripts: keeps #links in a post from refetching the page (ui/garden/hash-jump.mjs). */}
88
- <script dangerouslySetInnerHTML={{ __html: HASH_JUMP_SCRIPT }} />
89
86
  {mentionEndpoint && <link rel="webmention" href={mentionEndpoint} />}
90
87
  {analyticsDomain && (
91
88
  <script
package/app/styles/a2.css CHANGED
@@ -466,7 +466,7 @@ html[data-theme="dark"] .markdown-body a.ext {
466
466
  html[data-theme="dark"] .markdown-body img.fav {
467
467
  padding: 1px;
468
468
  box-sizing: border-box;
469
- background: #f5f4ed;
469
+ background: var(--image-backing);
470
470
  }
471
471
 
472
472
  .markdown-body .table-wrap a.ext img.fav,
@@ -685,6 +685,18 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
685
685
  height: auto;
686
686
  }
687
687
 
688
+ /* Dark mode: article images sit on the light paper colour, so a transparent
689
+ image (black lines on nothing, like most SVG diagrams) stays legible and an
690
+ opaque one looks the same. The backing is the image's own background, so it
691
+ takes the image's own corner radius; no padding, no filters. Favicons, link
692
+ card thumbnails, covers, avatars and the logo are not article images. */
693
+ html[data-theme="dark"] .markdown-image-trigger img,
694
+ html[data-theme="dark"] .markdown-image-lightbox-content img,
695
+ html[data-theme="dark"] .markdown-body > img,
696
+ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
697
+ background: var(--image-backing);
698
+ }
699
+
688
700
  .markdown-body .fig {
689
701
  margin: 36px 0;
690
702
  }
@@ -61,6 +61,9 @@
61
61
 
62
62
  :root[data-theme="dark"],
63
63
  html[data-theme="dark"] {
64
+ /* Light backing under article images and link icons, so dark-on-transparent
65
+ artwork stays legible. */
66
+ --image-backing: #faf9f5;
64
67
  --paper: #1f1e1d;
65
68
  --paper-deep: #262624;
66
69
  --card: #262624;
@@ -14,9 +14,10 @@ import {
14
14
  webmentionApiEndpoint,
15
15
  } from "../../lib/site";
16
16
  import { siteHref } from "../../lib/site-href";
17
+ import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
17
18
 
18
19
  export function generateStaticParams() {
19
- return thoughts.map((thought) => ({ slug: thought.slug }));
20
+ return staticParams(thoughts.map((thought) => ({ slug: thought.slug })), { slug: STATIC_PLACEHOLDER });
20
21
  }
21
22
 
22
23
  export async function generateMetadata({
@@ -60,7 +61,10 @@ export default async function ThoughtPage({
60
61
  }) {
61
62
  const { slug } = await params;
62
63
  const thought = findPublishedThought(slug);
63
- if (!thought) notFound();
64
+ if (!thought) {
65
+ if (isStaticPlaceholder(slug)) return null;
66
+ notFound();
67
+ }
64
68
  const baseUrl = await getSiteUrl();
65
69
  const pathname = siteHref(`/thoughts/${slug}`);
66
70
 
package/bin/garden.mjs CHANGED
@@ -391,8 +391,14 @@ switch (command) {
391
391
  // Sizes of remote files in garden file blocks; failures fall back to file-sizes.json.
392
392
  if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/file-sizes.mjs");
393
393
  runVinext(["build", ...rest], { copyDist: true });
394
+ // /favicon.ico from the site's own icon (brand.favicon).
395
+ runNode("scripts/favicon-ico.mjs");
394
396
  // Pages for old addresses listed under redirects in site.config.yaml (static export only).
395
- if (process.env.STATIC_EXPORT === "1") runNode("scripts/redirects.mjs");
397
+ if (process.env.STATIC_EXPORT === "1") {
398
+ // What the export wrote for empty dynamic routes (ui/garden/static-params.mjs).
399
+ runNode("scripts/static-placeholders.mjs");
400
+ runNode("scripts/redirects.mjs");
401
+ }
396
402
  break;
397
403
  case "links":
398
404
  runNode("scripts/link-assets.mjs");
package/next.config.ts CHANGED
@@ -10,6 +10,11 @@ const nextConfig: NextConfig = {
10
10
  : {}),
11
11
  // Always on in a static export; a server build opts in with TRAILING_SLASH=1. Links follow it (siteHref).
12
12
  trailingSlash: trailingSlashEnabled(process.env),
13
+ // vinext 1.x, like Next.js, streams page metadata after the document shell to
14
+ // clients that run JavaScript: title, canonical, RSS and robots would sit at the
15
+ // end of <body> until a script moves them. Treating every user agent as
16
+ // HTML-limited keeps them in <head> for everyone, as with vinext 0.0.x.
17
+ htmlLimitedBots: /.*/,
13
18
  };
14
19
 
15
20
  export default nextConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.17",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -77,7 +77,7 @@
77
77
  "@phosphor-icons/react": "^2.1.10",
78
78
  "@tailwindcss/postcss": "4.3.3",
79
79
  "@vitejs/plugin-react": "6.1.2",
80
- "@vitejs/plugin-rsc": "0.5.30",
80
+ "@vitejs/plugin-rsc": "0.5.38",
81
81
  "acorn": "^8.19.0",
82
82
  "fuse.js": "^7.5.0",
83
83
  "github-slugger": "^2.0.0",
@@ -93,10 +93,10 @@
93
93
  "micromark-extension-mdx-expression": "^3.0.1",
94
94
  "micromark-extension-mdx-jsx": "^3.0.2",
95
95
  "next": "16.3.0",
96
- "react": "19.3.0",
97
- "react-dom": "19.3.0",
96
+ "react": "19.3.1",
97
+ "react-dom": "19.3.1",
98
98
  "react-markdown": "^10.1.0",
99
- "react-server-dom-webpack": "19.3.0",
99
+ "react-server-dom-webpack": "19.3.1",
100
100
  "rehype-highlight": "^7.0.2",
101
101
  "rehype-katex": "^7.0.1",
102
102
  "rehype-raw": "^7.0.0",
@@ -108,11 +108,12 @@
108
108
  "remark-math": "^6.0.0",
109
109
  "remark-parse": "^11.0.0",
110
110
  "remark-rehype": "^11.1.2",
111
+ "sharp": "^0.34.5",
111
112
  "tailwindcss": "4.2.1",
112
113
  "typescript": "5.9.3",
113
114
  "unified": "^11.0.5",
114
115
  "unist-util-visit": "^5.1.0",
115
- "vinext": "0.0.50",
116
+ "vinext": "1.2.1",
116
117
  "vite": "8.0.13",
117
118
  "wrangler": "4.116.0",
118
119
  "yaml": "^2.9.0"
@@ -10,6 +10,7 @@ import { countDemoWords } from "../ui/garden/word-count.mjs";
10
10
  import { referenceDomain } from "../ui/garden/references.mjs";
11
11
  import { linkAssetsForRender } from "./link-assets.mjs";
12
12
  import { cardUrls } from "../ui/garden/markdown-links.mjs";
13
+ import { formatInlineLink, replaceInlineLinks } from "../ui/garden/markdown-destination.mjs";
13
14
  import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
14
15
  import { resolveBrand } from "./brand.mjs";
15
16
  import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
@@ -173,10 +174,10 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
173
174
  }
174
175
 
175
176
  function rewriteMarkdownAssets(content, markdownPath, sourcePath) {
176
- const markdownImages = content.replace(
177
- /(!\[[^\]]*\]\()<?([^)\s>]+)>?((?:\s+["'][^"']*["'])?\))/gu,
178
- (_, prefix, src, suffix) =>
179
- `${prefix}${copyLocalAsset(src, markdownPath, sourcePath)}${suffix}`,
177
+ // `![alt](<a b (1).png>)` keeps its angle brackets: without them the space
178
+ // and the parentheses would no longer be one destination.
179
+ const markdownImages = replaceInlineLinks(content, (link) =>
180
+ link.image ? formatInlineLink(link, copyLocalAsset(link.url, markdownPath, sourcePath)) : undefined,
180
181
  );
181
182
 
182
183
  return markdownImages.replace(
@@ -7,6 +7,7 @@ import { gardenSiteRoot } from "./garden-paths.mjs";
7
7
  import { checkUiBlock } from "../ui/core/validate.mjs";
8
8
  import { checkGardenMarkdown } from "../ui/garden/check.mjs";
9
9
  import { checkFileSources } from "../ui/garden/file-paths.mjs";
10
+ import { inlineLinkParts, inlineLinkPattern } from "../ui/garden/markdown-destination.mjs";
10
11
 
11
12
  const siteRoot = gardenSiteRoot();
12
13
  const calloutTypes = new Set([
@@ -247,8 +248,9 @@ const scanBody = (errors, content, markdownPath, sourcePath, { warnings = [], dr
247
248
  error(errors, "callout-type", lineNumber, `${sourcePath}: 不支持的警告框类型 ${callout[1]}`);
248
249
  }
249
250
 
250
- for (const image of line.matchAll(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gu)) {
251
- assertLocalFile(errors, image[2], path.dirname(markdownPath), sourcePath, lineNumber, "图片");
251
+ for (const match of line.matchAll(inlineLinkPattern())) {
252
+ const image = inlineLinkParts(match);
253
+ if (image.image) assertLocalFile(errors, image.url, path.dirname(markdownPath), sourcePath, lineNumber, "图片");
252
254
  }
253
255
  });
254
256
 
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * /favicon.ico for browsers, feed readers and crawlers that ask for it
4
+ * regardless of <link rel="icon">. Made from the site's own icon (`brand.favicon`
5
+ * in site.config.yaml, Garden's favicon.svg when unset): 16, 32 and 48 px PNG
6
+ * images in one ICO file. Runs after the build, into dist/client.
7
+ *
8
+ * A favicon.ico the site already has in public/ is left alone. An icon given
9
+ * as a full URL, or a file that is missing or cannot be read, only prints a
10
+ * warning: the page itself still names its icon.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { parse as parseYaml } from "yaml";
16
+ import { resolveBrand } from "./brand.mjs";
17
+
18
+ export const ICO_SIZES = [16, 32, 48];
19
+
20
+ /** An ICO file holding one PNG image per size (Windows Vista and every current browser read PNG entries). */
21
+ export function icoFromPngs(images) {
22
+ const header = Buffer.alloc(6);
23
+ header.writeUInt16LE(0, 0);
24
+ header.writeUInt16LE(1, 2);
25
+ header.writeUInt16LE(images.length, 4);
26
+ const entries = [];
27
+ let offset = 6 + 16 * images.length;
28
+ for (const { size, png } of images) {
29
+ const entry = Buffer.alloc(16);
30
+ entry.writeUInt8(size >= 256 ? 0 : size, 0);
31
+ entry.writeUInt8(size >= 256 ? 0 : size, 1);
32
+ entry.writeUInt8(0, 2);
33
+ entry.writeUInt8(0, 3);
34
+ entry.writeUInt16LE(1, 4);
35
+ entry.writeUInt16LE(32, 6);
36
+ entry.writeUInt32LE(png.length, 8);
37
+ entry.writeUInt32LE(offset, 12);
38
+ offset += png.length;
39
+ entries.push(entry);
40
+ }
41
+ return Buffer.concat([header, ...entries, ...images.map((image) => image.png)]);
42
+ }
43
+
44
+ /** The icon file behind a brand.favicon URL: public/ first, then what the build copied. Null for remote icons. */
45
+ export function faviconSource(siteRoot, outDir, faviconUrl, basePath = "") {
46
+ if (/^(?:[a-z]+:)?\/\//iu.test(faviconUrl) || faviconUrl.startsWith("data:")) return null;
47
+ let local = faviconUrl.split(/[?#]/u)[0];
48
+ if (basePath && local.startsWith(`${basePath}/`)) local = local.slice(basePath.length);
49
+ local = decodeURIComponent(local).replace(/^\/+/u, "");
50
+ for (const root of [path.join(siteRoot, "public"), outDir]) {
51
+ const file = path.resolve(root, local);
52
+ if (file.startsWith(path.resolve(root) + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile()) return file;
53
+ }
54
+ return "";
55
+ }
56
+
57
+ /** Writes outDir/favicon.ico from the source icon. Returns what happened, for the log and the tests. */
58
+ export async function writeFaviconIco({ source, outDir, siteRoot }) {
59
+ const target = path.join(outDir, "favicon.ico");
60
+ if (siteRoot && fs.existsSync(path.join(siteRoot, "public", "favicon.ico"))) return { status: "kept", target };
61
+ if (/\.ico$/iu.test(source)) {
62
+ fs.copyFileSync(source, target);
63
+ return { status: "copied", target };
64
+ }
65
+ const { default: sharp } = await import("sharp");
66
+ const input = fs.readFileSync(source);
67
+ const images = [];
68
+ for (const size of ICO_SIZES) {
69
+ // SVG is rasterised at the target size, not scaled down from a bitmap.
70
+ const density = /\.svg$/iu.test(source) ? Math.max(72, Math.ceil((72 * size) / 16)) : undefined;
71
+ const png = await sharp(input, density ? { density } : {})
72
+ .resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
73
+ .png()
74
+ .toBuffer();
75
+ images.push({ size, png });
76
+ }
77
+ fs.writeFileSync(target, icoFromPngs(images));
78
+ return { status: "written", target, sizes: ICO_SIZES };
79
+ }
80
+
81
+ export async function runFaviconStep({ siteRoot, env = process.env, out = process.stdout }) {
82
+ const outDir = path.join(siteRoot, "dist", "client");
83
+ const warn = (line) => out.write(`警告:${line}\n`);
84
+ if (!fs.existsSync(outDir)) {
85
+ warn(`找不到构建产物 ${path.relative(siteRoot, outDir)},没有生成 favicon.ico。`);
86
+ return 0;
87
+ }
88
+ const configFile = path.join(siteRoot, "site.config.yaml");
89
+ const config = fs.existsSync(configFile) ? parseYaml(fs.readFileSync(configFile, "utf8")) ?? {} : {};
90
+ const basePath = (env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/u, "");
91
+ const { favicon } = resolveBrand(siteRoot, config, basePath);
92
+ const source = faviconSource(siteRoot, outDir, favicon, basePath);
93
+ if (source === null) {
94
+ warn(`brand.favicon 是外部地址 ${favicon},没有生成 favicon.ico。`);
95
+ return 0;
96
+ }
97
+ if (!source) {
98
+ warn(`找不到图标文件 ${favicon},没有生成 favicon.ico。`);
99
+ return 0;
100
+ }
101
+ try {
102
+ const result = await writeFaviconIco({ source, outDir, siteRoot });
103
+ if (result.status === "kept") out.write("favicon.ico:使用 public/favicon.ico。\n");
104
+ else out.write(`favicon.ico:由 ${path.relative(siteRoot, source)} 生成${result.sizes ? `(${result.sizes.join("、")} px)` : ""}。\n`);
105
+ } catch (error) {
106
+ warn(`favicon.ico 生成失败(${error.message}),页面仍使用 ${favicon}。`);
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
112
+ const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
113
+ process.exitCode = await runFaviconStep({ siteRoot });
114
+ }
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { STATIC_PLACEHOLDER } from "../ui/garden/static-params.mjs";
5
6
 
6
7
  const forbiddenDirectoryNames = new Set([
7
8
  ".git",
@@ -55,6 +56,7 @@ async function inspectTree(root, directory = root) {
55
56
  fail(`不允许源码目录:${relativePath}`);
56
57
  }
57
58
  if (entry.name.startsWith(".env")) fail(`不允许环境文件:${relativePath}`);
59
+ if (entry.name.startsWith(STATIC_PLACEHOLDER)) fail(`空动态路由的占位页没有删掉:${relativePath}`);
58
60
  if (/^package(?:-lock)?\.json$/u.test(entry.name)) {
59
61
  fail(`不允许依赖清单:${relativePath}`);
60
62
  }
@@ -106,7 +108,8 @@ async function stabilizeDeploymentVersion(root) {
106
108
  for (const file of await listFiles(root)) {
107
109
  const original = await fs.readFile(file.absolutePath);
108
110
  let normalized = original;
109
- if ([".html", ".rsc"].includes(path.extname(file.relativePath))) {
111
+ // Flight payloads: .rsc from vinext 0.0.x, index.txt / <page>.txt from vinext 1.x static export.
112
+ if ([".html", ".rsc", ".txt"].includes(path.extname(file.relativePath))) {
110
113
  const text = original.toString("utf8");
111
114
  normalized = Buffer.from(
112
115
  normalizeDeploymentVersion(text, deploymentVersionPlaceholder),
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deletes what the static export wrote for STATIC_PLACEHOLDER (see
4
+ * ui/garden/static-params.mjs): the directory or file of every path segment
5
+ * named after it. Runs after the build, on dist/client.
6
+ *
7
+ * Also deletes 404/index.html when it is a copy of 404.html: with trailingSlash
8
+ * vinext writes both, and on a static host the copy makes /404/ answer 200.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { STATIC_PLACEHOLDER } from "../ui/garden/static-params.mjs";
14
+
15
+ /** Removes placeholder output under root and returns the removed paths, relative to root. */
16
+ export function removeStaticPlaceholders(root) {
17
+ const removed = [];
18
+ const visit = (directory) => {
19
+ if (!fs.existsSync(directory)) return;
20
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
21
+ const absolute = path.join(directory, entry.name);
22
+ if (entry.name === STATIC_PLACEHOLDER || entry.name.startsWith(`${STATIC_PLACEHOLDER}.`)) {
23
+ fs.rmSync(absolute, { recursive: true, force: true });
24
+ removed.push(path.relative(root, absolute).split(path.sep).join("/"));
25
+ } else if (entry.isDirectory()) {
26
+ visit(absolute);
27
+ }
28
+ }
29
+ };
30
+ visit(root);
31
+ return removed.sort();
32
+ }
33
+
34
+ /** Deletes 404/index.html (and the then empty 404/) when it is byte-for-byte 404.html. Returns whether it did. */
35
+ export function removeNotFoundCopy(root) {
36
+ const page = path.join(root, "404.html");
37
+ const copy = path.join(root, "404", "index.html");
38
+ if (!fs.existsSync(page) || !fs.existsSync(copy) || !fs.readFileSync(page).equals(fs.readFileSync(copy))) return false;
39
+ fs.rmSync(copy);
40
+ if (!fs.readdirSync(path.dirname(copy)).length) fs.rmdirSync(path.dirname(copy));
41
+ return true;
42
+ }
43
+
44
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
45
+ const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
46
+ removeStaticPlaceholders(path.join(siteRoot, "dist", "client"));
47
+ removeNotFoundCopy(path.join(siteRoot, "dist", "client"));
48
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Inline links and images, `[label](destination "title")` and
3
+ * `![alt](destination "title")`, read the way CommonMark reads them. A
4
+ * destination is either
5
+ * - `<…>`: anything but a line break or an unescaped `<` / `>`, so it may hold
6
+ * spaces and unbalanced parentheses: `![a](<./my shot (1).png>)`; or
7
+ * - a bare run without spaces, where parentheses must balance:
8
+ * `[w](https://en.wikipedia.org/wiki/Trie_(data_structure))`.
9
+ * The build, the checker, the draft preview, link cards and the search index
10
+ * all use this one reading, so a destination that works in the article also
11
+ * works everywhere else, and a rewritten destination keeps its brackets.
12
+ */
13
+
14
+ const ESCAPED = String.raw`\\.`;
15
+ const ANGLE = String.raw`<((?:[^<>\n\\]|${ESCAPED})*)>`;
16
+ const PLAIN = String.raw`(?:[^\s()<\\]|${ESCAPED})`;
17
+ const INNER = String.raw`(?:[^\s()\\]|${ESCAPED})`;
18
+ // Balanced parentheses, nested up to three deep.
19
+ const PARENS = [1, 2, 3].reduce((inner) => String.raw`\((?:${INNER}|${inner})*\)`, String.raw`\((?:${INNER})*\)`);
20
+ const BARE = String.raw`((?:${PLAIN}|${PARENS})(?:${INNER}|${PARENS})*)`;
21
+ const LABEL = String.raw`(?:[^\[\]\\]|${ESCAPED}|\[(?:[^\[\]\\]|${ESCAPED})*\])*`;
22
+
23
+ /**
24
+ * Source of the inline link pattern. Groups: 1 `!` for an image, 2 label,
25
+ * 3 destination written in `<…>`, 4 bare destination, 5 title (with the space
26
+ * before it).
27
+ */
28
+ export const INLINE_LINK_SOURCE = String.raw`(!?)\[(${LABEL})\]\(\s*(?:${ANGLE}|${BARE})((?:\s+(?:"(?:[^"\\]|${ESCAPED})*"|'(?:[^'\\]|${ESCAPED})*'|\((?:[^()\\]|${ESCAPED})*\)))?)\s*\)`;
29
+
30
+ /**
31
+ * A fresh global pattern for inline links and images (`flags` adds e.g. `i`).
32
+ * @param {string} [flags]
33
+ */
34
+ export const inlineLinkPattern = (flags = "") => new RegExp(INLINE_LINK_SOURCE, `gu${flags.replace(/[gu]/gu, "")}`);
35
+
36
+ /**
37
+ * @typedef {{ whole: string, image: boolean, label: string, url: string, angle: boolean, title: string }} InlineLink
38
+ */
39
+
40
+ /**
41
+ * The parts of one match of inlineLinkPattern().
42
+ * @param {ArrayLike<string | undefined>} match
43
+ * @returns {InlineLink}
44
+ */
45
+ export function inlineLinkParts(match) {
46
+ const angle = match[3] !== undefined;
47
+ return { whole: String(match[0]), image: match[1] === "!", label: String(match[2] ?? ""), url: String((angle ? match[3] : match[4]) ?? ""), angle, title: match[5] ?? "" };
48
+ }
49
+
50
+ /**
51
+ * A destination written back the way it was written: in `<…>` if it was.
52
+ * @param {string} url
53
+ * @param {boolean} angle
54
+ */
55
+ export const formatDestination = (url, angle) => (angle ? `<${url}>` : url);
56
+
57
+ /**
58
+ * `[label](destination title)` or `![…](…)` from parts, with a new url.
59
+ * @param {Pick<InlineLink, "image" | "label" | "angle" | "title">} link
60
+ * @param {string} url
61
+ */
62
+ export const formatInlineLink = ({ image, label, angle, title }, url) =>
63
+ `${image ? "!" : ""}[${label}](${formatDestination(url, angle)}${title})`;
64
+
65
+ /**
66
+ * Replace every inline link and image. `replace(parts)` returns the new text,
67
+ * or undefined to leave that link as written.
68
+ * @param {string} text
69
+ * @param {(link: InlineLink) => string | undefined} replace
70
+ * @returns {string}
71
+ */
72
+ export function replaceInlineLinks(text, replace) {
73
+ return String(text ?? "").replace(inlineLinkPattern(), (...match) => {
74
+ const next = replace(inlineLinkParts(/** @type {string[]} */ (match)));
75
+ return next === undefined ? match[0] : next;
76
+ });
77
+ }
@@ -2,6 +2,7 @@
2
2
  * External links written in a post body. Code (fenced blocks, indented blocks
3
3
  * and inline spans) is skipped, so a URL in a snippet is never a link.
4
4
  */
5
+ import { INLINE_LINK_SOURCE } from "./markdown-destination.mjs";
5
6
 
6
7
  const FENCE = /^ {0,3}(`{3,}|~{3,})/u;
7
8
 
@@ -47,7 +48,12 @@ function mapInline(text, visit) {
47
48
  return result + visit(text.slice(index));
48
49
  }
49
50
 
50
- const LINK = /(!?)\[(?:[^\[\]]|\[[^\]]*\])*\]\(\s*<?(https?:\/\/[^\s)>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)(\{card\})?/giu;
51
+ // Inline links read as CommonMark reads them (ui/garden/markdown-destination.mjs):
52
+ // `[t](<https://e.com/a (1)>)` and `[t](https://e.com/Trie_(x))` are whole addresses.
53
+ // Groups: 1 `!`, 3 or 4 the address, 6 `{card}`.
54
+ const LINK = new RegExp(`${INLINE_LINK_SOURCE}(\\{card\\})?`, "giu");
55
+ const linkUrl = (match) => match[3] ?? match[4];
56
+ const isWebUrl = (url) => /^https?:\/\//iu.test(url);
51
57
  const AUTOLINK = /<(https?:\/\/[^\s>]+)>/giu;
52
58
  const BARE = /(^|[\s((::,,。])(https?:\/\/[^\s<>()()\],。、]+[^\s<>()()\].,;:!?,。、;:!?'"])/giu;
53
59
 
@@ -56,7 +62,7 @@ export function markdownLinks(markdown) {
56
62
  const links = [];
57
63
  mapOutsideCode(markdown, (text) => {
58
64
  for (const match of text.matchAll(LINK)) {
59
- if (match[1] !== "!") links.push({ url: match[2], card: Boolean(match[3]) });
65
+ if (match[1] !== "!" && isWebUrl(linkUrl(match))) links.push({ url: linkUrl(match), card: Boolean(match[6]) });
60
66
  }
61
67
  const rest = text.replace(LINK, " ");
62
68
  for (const match of rest.matchAll(AUTOLINK)) links.push({ url: match[1], card: false });
@@ -75,7 +81,7 @@ export const CARD_MARKER = "{card}";
75
81
  */
76
82
  export function stripCardMarkers(markdown) {
77
83
  if (!String(markdown ?? "").includes(CARD_MARKER)) return markdown;
78
- return mapOutsideCode(markdown, (text) => text.replace(LINK, (whole, bang, url, card) => (card ? whole.slice(0, -CARD_MARKER.length) : whole)));
84
+ return mapOutsideCode(markdown, (text) => text.replace(LINK, (...match) => (match[6] && isWebUrl(linkUrl(match)) ? match[0].slice(0, -CARD_MARKER.length) : match[0])));
79
85
  }
80
86
 
81
87
  /** URLs the author asked to show as cards, in order, without repeats. */
@@ -8,13 +8,17 @@
8
8
  */
9
9
  import crypto from "node:crypto";
10
10
  import { contentToSearchText } from "../core/transform.mjs";
11
+ import { inlineLinkPattern } from "./markdown-destination.mjs";
12
+
13
+ const INLINE_LINK = inlineLinkPattern();
11
14
 
12
15
  /** Plain text of a post body, as search and word counts see it. */
13
16
  export function searchableText(markdown) {
14
17
  return contentToSearchText(markdown)
15
18
  .replace(/```[^\n]*\n([\s\S]*?)```/g, " $1 ")
16
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, " $1 ")
17
- .replace(/\[([^\]]+)\]\([^)]+\)/g, " $1 ")
19
+ // Links and images leave their text; the address goes whole, even with
20
+ // parentheses or in <…> (ui/garden/markdown-destination.mjs).
21
+ .replace(INLINE_LINK, (...match) => ` ${match[2]} `)
18
22
  .replace(/<[^>]+>/g, " ")
19
23
  .replace(/[#>*_~`|[\]{}()]/g, " ")
20
24
  .replace(/\s+/g, " ")