hd-garden 0.5.13 → 0.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/app/a2/page.tsx +2 -1
  2. package/app/about/page.tsx +2 -1
  3. package/app/columns/[column]/[...path]/page.tsx +2 -1
  4. package/app/components/ArticleTags.tsx +6 -3
  5. package/app/components/BlogArchive.tsx +3 -2
  6. package/app/components/ContentEmptyState.tsx +2 -1
  7. package/app/components/MarkdownArticle.tsx +40 -3
  8. package/app/components/SiteHeader.tsx +18 -15
  9. package/app/components/TagRedirect.tsx +4 -3
  10. package/app/components/ThoughtCard.tsx +3 -2
  11. package/app/components/garden/FilePreview.tsx +176 -0
  12. package/app/components/garden/GardenBlock.tsx +4 -0
  13. package/app/components/garden/GardenFile.tsx +69 -0
  14. package/app/components/garden/file-icons.tsx +49 -0
  15. package/app/garden-modules.d.ts +1 -1
  16. package/app/layout.tsx +3 -0
  17. package/app/lib/content.ts +5 -3
  18. package/app/lib/markdown-plugins.ts +2 -11
  19. package/app/lib/site-href.ts +9 -0
  20. package/app/lib/tag-filter.ts +8 -0
  21. package/app/page.tsx +5 -4
  22. package/app/rss.xml/route.ts +6 -3
  23. package/app/sitemap.ts +4 -3
  24. package/app/styles/a2.css +72 -0
  25. package/app/styles/shell.css +21 -2
  26. package/app/tags/page.tsx +3 -2
  27. package/app/thoughts/[slug]/page.tsx +4 -3
  28. package/bin/garden.mjs +10 -2
  29. package/next.config.ts +3 -1
  30. package/package.json +3 -1
  31. package/scripts/build-content.mjs +110 -10
  32. package/scripts/check-post.mjs +45 -0
  33. package/scripts/file-preview.mjs +138 -0
  34. package/scripts/file-sizes.mjs +146 -0
  35. package/scripts/prepare-pages-output.mjs +7 -1
  36. package/scripts/redirects.mjs +80 -55
  37. package/templates/garden-blocks.md +11 -0
  38. package/templates/garden-snippets.plist +9 -0
  39. package/ui/core/transform.mjs +18 -0
  40. package/ui/garden/catalog.mjs +15 -1
  41. package/ui/garden/check.mjs +12 -0
  42. package/ui/garden/file-block.mjs +232 -0
  43. package/ui/garden/file-paths.mjs +65 -0
  44. package/ui/garden/hash-jump.mjs +37 -0
  45. package/ui/garden/internal-links.mjs +113 -0
  46. package/ui/garden/markdown-syntax.mjs +12 -0
  47. package/ui/garden/parse.mjs +1 -1
  48. package/ui/garden/same-site.mjs +27 -0
  49. package/ui/garden/site-href.mjs +62 -0
  50. package/ui/garden/site-nav.mjs +25 -0
  51. package/ui/garden/snippets.mjs +2 -2
  52. package/ui/garden/word-count.mjs +5 -0
  53. package/ui/preview/dist/garden-ui.esm.js +33 -33
  54. package/ui/preview/dist/garden-ui.iife.js +32 -32
@@ -6,6 +6,7 @@ import { canonicalSiteDate } from "./site-date.mjs";
6
6
  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
+ import { checkFileSources } from "../ui/garden/file-paths.mjs";
9
10
 
10
11
  const siteRoot = gardenSiteRoot();
11
12
  const calloutTypes = new Set([
@@ -255,6 +256,45 @@ const scanBody = (errors, content, markdownPath, sourcePath, { warnings = [], dr
255
256
  if (fence?.lang === "embed") checkEmbed(errors, fence.body.join("\n"), sourcePath, fence.line);
256
257
  };
257
258
 
259
+ const markdownBelow = (directory) =>
260
+ fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
261
+ const full = path.join(directory, entry.name);
262
+ if (entry.isDirectory()) return markdownBelow(full);
263
+ return entry.name.endsWith(".md") ? [full] : [];
264
+ });
265
+
266
+ /** Does the post's Markdown point at this file (garden file, a link)? Then it is there on purpose. */
267
+ const postUses = (postRaw, postPath, file) => {
268
+ const relative = path.relative(path.dirname(postPath), file).split(path.sep).join("/");
269
+ return postRaw.includes(`./${relative}`) || postRaw.includes(relative);
270
+ };
271
+
272
+ /**
273
+ * `post.md` + `post/` is the post's folder of files, and the build no longer
274
+ * publishes Markdown inside it as a post. Name each such file, so one that used
275
+ * to be a post does not disappear from the site unnoticed.
276
+ */
277
+ const checkFolderMarkdown = (warnings, absolute, raw) => {
278
+ const contentRoot = path.join(siteRoot, "content");
279
+ const warn = (file, post) => warnings.push({
280
+ code: "folder-markdown",
281
+ line: 1,
282
+ message: `${path.relative(siteRoot, file)}: 在 ${path.relative(siteRoot, post)} 的同名目录里,不会作为文章发布。建议:它是给读者下载或预览的文件,就在文章里用 garden file 引用;要单独发布,把它移出 ${path.relative(siteRoot, post.replace(/\.md$/u, ""))}/。`,
283
+ });
284
+ const folder = absolute.replace(/\.md$/u, "");
285
+ if (fs.existsSync(folder) && fs.statSync(folder).isDirectory()) {
286
+ for (const file of markdownBelow(folder).sort()) if (!postUses(raw, absolute, file)) warn(file, absolute);
287
+ }
288
+ // Checked directly: the file itself sits in some post's folder.
289
+ for (let dir = path.dirname(absolute); dir.startsWith(contentRoot + path.sep); dir = path.dirname(dir)) {
290
+ const post = `${dir}.md`;
291
+ if (fs.existsSync(post)) {
292
+ if (!postUses(fs.readFileSync(post, "utf8"), post, absolute)) warn(absolute, post);
293
+ break;
294
+ }
295
+ }
296
+ };
297
+
258
298
  const checkFile = (filePath) => {
259
299
  const absolute = path.resolve(siteRoot, filePath);
260
300
  const sourcePath = path.relative(siteRoot, absolute);
@@ -300,6 +340,11 @@ const checkFile = (filePath) => {
300
340
  const target = item.severity === "warning" ? warnings : errors;
301
341
  target.push({ code: item.code, line: item.line, message: `${sourcePath}: ${item.message} 建议:${item.suggestion}` });
302
342
  }
343
+ for (const item of checkFileSources(raw, absolute, path.join(siteRoot, "content"))) {
344
+ const target = item.severity === "warning" ? warnings : errors;
345
+ target.push({ code: item.code, line: item.line, message: `${sourcePath}: ${item.message} 建议:${item.suggestion}` });
346
+ }
347
+ checkFolderMarkdown(warnings, absolute, raw);
303
348
  return { file: sourcePath, errors, warnings };
304
349
  };
305
350
 
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The 预览 of a Markdown file in a `garden file` block, rendered at build time.
3
+ *
4
+ * The HTML goes to its own file next to the copied Markdown and is fetched
5
+ * only when a reader opens the preview, so the article page does not carry
6
+ * it. It is read with the posts' Markdown syntax (markdownSyntaxPlugins) and
7
+ * the posts' rehype steps: raw HTML, KaTeX, heading ids, highlighting.
8
+ * What only React adds to a post (copy buttons, Mermaid, link cards) is not
9
+ * there: a preview is a document to read.
10
+ *
11
+ * The client puts this HTML into the page, so anything that would run is
12
+ * removed here: script-like elements, on* attributes, javascript: and other
13
+ * non-http URLs (images may still use data:).
14
+ */
15
+ import rehypeHighlight from "rehype-highlight";
16
+ import rehypeKatex from "rehype-katex";
17
+ import rehypeRaw from "rehype-raw";
18
+ import rehypeSlug from "rehype-slug";
19
+ import rehypeStringify from "rehype-stringify";
20
+ import remarkParse from "remark-parse";
21
+ import remarkRehype from "remark-rehype";
22
+ import { unified } from "unified";
23
+ import { visit } from "unist-util-visit";
24
+ import { markdownSyntaxPlugins } from "../ui/garden/markdown-syntax.mjs";
25
+
26
+ const DROPPED_ELEMENTS = new Set(["script", "noscript", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "link", "meta", "form", "template"]);
27
+ const URL_PROPERTIES = new Set(["href", "src", "action", "formAction", "poster", "cite", "background", "xLinkHref"]);
28
+ const SAFE_URL = /^(?:https?:|mailto:|tel:|#|\/(?!\/)|\.{0,2}\/|[^:/?#]+(?:[/?#]|$))/iu;
29
+
30
+ const isSafeUrl = (name, value, tagName) => {
31
+ const url = String(value).trim().replace(/[\u0000-\u001f\s]+/gu, "");
32
+ if (name === "src" && tagName === "img" && /^data:image\/(?:png|gif|jpe?g|webp|avif);/iu.test(url)) return true;
33
+ return SAFE_URL.test(url);
34
+ };
35
+
36
+ /** Removes what could run once the HTML is in the page. */
37
+ export function rehypePreviewSafety() {
38
+ return (tree) => {
39
+ visit(tree, "element", (node, index, parent) => {
40
+ if (DROPPED_ELEMENTS.has(node.tagName)) {
41
+ parent.children.splice(index, 1);
42
+ return index;
43
+ }
44
+ for (const name of Object.keys(node.properties ?? {})) {
45
+ const value = node.properties[name];
46
+ if (/^on/iu.test(name) || name === "srcDoc" || name === "style" && /expression|url\s*\(\s*['"]?\s*javascript:/iu.test(String(value))) {
47
+ delete node.properties[name];
48
+ } else if (URL_PROPERTIES.has(name) && !isSafeUrl(name, value, node.tagName)) {
49
+ delete node.properties[name];
50
+ } else if (name === "srcSet" && [].concat(value).some((part) => !isSafeUrl("srcset", String(part).trim().split(/\s+/u)[0], node.tagName))) {
51
+ delete node.properties[name];
52
+ }
53
+ }
54
+ if (node.tagName === "a" && /^https?:/iu.test(String(node.properties?.href ?? ""))) {
55
+ node.properties.target = "_blank";
56
+ node.properties.rel = ["noopener", "noreferrer"];
57
+ }
58
+ return undefined;
59
+ });
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The markup the article's own styles expect, so the drawer looks like the
65
+ * article: tables scroll inside .table-wrap, code blocks are pre.code with
66
+ * their language, outside links carry .ext. (Copy buttons, link cards and
67
+ * favicons are article-only.)
68
+ */
69
+ export function rehypePreviewShape() {
70
+ return (tree) => {
71
+ visit(tree, "element", (node, index, parent) => {
72
+ if (node.tagName === "table" && !(parent?.type === "element" && parent.properties?.className?.includes?.("table-wrap"))) {
73
+ parent.children[index] = {
74
+ type: "element",
75
+ tagName: "div",
76
+ properties: { className: ["table-wrap"], role: "region", ariaLabel: "表格,可横向滚动", tabIndex: 0 },
77
+ children: [node],
78
+ };
79
+ return "skip";
80
+ }
81
+ if (node.tagName === "pre") {
82
+ 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);
84
+ node.properties.className = [...[].concat(node.properties.className ?? []), "code"];
85
+ if (language) node.properties.dataLanguage = language.toUpperCase();
86
+ }
87
+ if (node.tagName === "a" && node.properties?.target === "_blank") {
88
+ node.properties.className = [...[].concat(node.properties.className ?? []), "ext"];
89
+ }
90
+ return undefined;
91
+ });
92
+ };
93
+ }
94
+
95
+ /** Markdown → the preview's HTML. `idPrefix` keeps heading ids apart from the article's own. */
96
+ export function renderFilePreview(markdown, { idPrefix = "gf-" } = {}) {
97
+ return String(
98
+ unified()
99
+ .use(remarkParse)
100
+ .use(markdownSyntaxPlugins)
101
+ .use(remarkRehype, { allowDangerousHtml: true })
102
+ .use(rehypeRaw)
103
+ .use(rehypeKatex)
104
+ .use(rehypeSlug, { prefix: idPrefix })
105
+ .use(rehypeHighlight)
106
+ .use(rehypePreviewSafety)
107
+ .use(rehypePreviewShape)
108
+ .use(rehypeStringify)
109
+ .processSync(markdown),
110
+ );
111
+ }
112
+
113
+ const escapeText = (value) => String(value).replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
114
+
115
+ /**
116
+ * The published preview: a small standalone page, so a reader without
117
+ * JavaScript who follows the 预览 link gets something readable. The drawer
118
+ * takes only what is inside <main>.
119
+ */
120
+ export function previewDocument(html, { title = "预览" } = {}) {
121
+ return `<!doctype html>
122
+ <html lang="zh-CN">
123
+ <head>
124
+ <meta charset="utf-8">
125
+ <meta name="viewport" content="width=device-width, initial-scale=1">
126
+ <meta name="robots" content="noindex">
127
+ <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>
129
+ </head>
130
+ <body><main class="gf-preview-doc">
131
+ ${html}
132
+ </main></body>
133
+ </html>
134
+ `;
135
+ }
136
+
137
+ /** Where the preview of a copied file is published: next to it. */
138
+ export const previewFileName = (fileName) => `${fileName}.preview.html`;
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sizes of remote files named in `garden file` fences (OSS and other https
4
+ * links), so the card can say 「ZIP · 48 MB」 without the page asking anyone.
5
+ *
6
+ * Every build sends one HEAD request per URL (a one-byte range request when
7
+ * HEAD gives no length). Results go into `file-sizes.json`; commit it. When a
8
+ * request fails, the last known size is kept. A file that never answered has
9
+ * no size on its card. The build never fails here.
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import matter from "gray-matter";
15
+ import { isPublicUrl } from "./link-meta-core.mjs";
16
+ import { assertPublicDns, pool } from "./link-assets.mjs";
17
+ import { scanFences, parseGardenBlock } from "../ui/garden/parse.mjs";
18
+ import { isRemoteSrc, maskHtmlComments, readFileProps } from "../ui/garden/file-block.mjs";
19
+
20
+ export const CACHE_FILE = "file-sizes.json";
21
+ const TIMEOUT_MS = 8000;
22
+ const HEADERS = { "user-agent": "Mozilla/5.0 (compatible; GardenFileSize/1.0)" };
23
+
24
+ export function readFileSizeCache(siteRoot) {
25
+ try {
26
+ const data = JSON.parse(fs.readFileSync(path.join(siteRoot, CACHE_FILE), "utf8"));
27
+ return { files: data?.files && typeof data.files === "object" ? data.files : {} };
28
+ } catch {
29
+ return { files: {} };
30
+ }
31
+ }
32
+
33
+ /** Remote `src` values in the `garden file` fences of one Markdown body. */
34
+ export function remoteFileSources(markdown) {
35
+ const found = [];
36
+ for (const fence of scanFences(maskHtmlComments(markdown))) {
37
+ if (!/^garden\s+file\b/iu.test(fence.info ?? "")) continue;
38
+ const block = parseGardenBlock(fence.info, fence.body);
39
+ if (!block || block.error) continue;
40
+ for (const file of readFileProps(block.props).files) if (isRemoteSrc(file.src)) found.push(file.src);
41
+ }
42
+ return [...new Set(found)];
43
+ }
44
+
45
+ const lengthOf = (response) => {
46
+ const range = /\/(\d+)\s*$/u.exec(response.headers.get("content-range") ?? "")?.[1];
47
+ if (range) return Number(range);
48
+ const length = Number(response.headers.get("content-length"));
49
+ return Number.isFinite(length) && length > 0 && response.status !== 206 ? length : 0;
50
+ };
51
+
52
+ async function request(url, method, fetcher, checkHost) {
53
+ let current = url;
54
+ for (let hop = 0; hop < 4; hop += 1) {
55
+ if (!isPublicUrl(current)) throw new Error("not a public URL");
56
+ await checkHost(new URL(current).hostname);
57
+ const response = await fetcher(current, {
58
+ method,
59
+ redirect: "manual",
60
+ headers: method === "GET" ? { ...HEADERS, range: "bytes=0-0" } : HEADERS,
61
+ signal: AbortSignal.timeout(TIMEOUT_MS),
62
+ });
63
+ await response.body?.cancel().catch(() => {});
64
+ if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
65
+ current = new URL(response.headers.get("location"), current).toString();
66
+ continue;
67
+ }
68
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
69
+ return lengthOf(response);
70
+ }
71
+ throw new Error("too many redirects");
72
+ }
73
+
74
+ /** Size in bytes of a remote file: HEAD first, then a one-byte range GET. */
75
+ export async function probeFileSize(url, { fetch: fetcher = globalThis.fetch, checkHost = assertPublicDns } = {}) {
76
+ let headError = null;
77
+ try {
78
+ const size = await request(url, "HEAD", fetcher, checkHost);
79
+ if (size > 0) return size;
80
+ } catch (error) {
81
+ headError = error;
82
+ if (error?.message === "not a public URL") throw error;
83
+ }
84
+ const size = await request(url, "GET", fetcher, checkHost).catch((error) => {
85
+ throw headError ?? error;
86
+ });
87
+ if (size > 0) return size;
88
+ throw new Error("no length");
89
+ }
90
+
91
+ function markdownFiles(directory) {
92
+ if (!fs.existsSync(directory)) return [];
93
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
94
+ const full = path.join(directory, entry.name);
95
+ if (entry.isDirectory()) return markdownFiles(full);
96
+ return /\.mdx?$/iu.test(entry.name) ? [full] : [];
97
+ });
98
+ }
99
+
100
+ export function collectRemoteFiles(siteRoot) {
101
+ const urls = new Set();
102
+ for (const file of markdownFiles(path.join(siteRoot, "content"))) {
103
+ const { content } = matter(fs.readFileSync(file, "utf8"));
104
+ for (const src of remoteFileSources(content)) urls.add(src);
105
+ }
106
+ return [...urls].sort();
107
+ }
108
+
109
+ /**
110
+ * Asks every remote file for its size and writes the cache. A failed request
111
+ * keeps the cached size; a URL that never answered stays out of the cache.
112
+ */
113
+ export async function refreshFileSizes({ siteRoot, fetch: fetcher, checkHost, log = () => {}, concurrency = 6 }) {
114
+ const cache = readFileSizeCache(siteRoot);
115
+ const urls = collectRemoteFiles(siteRoot);
116
+ const files = {};
117
+ let failed = 0;
118
+ await pool(urls, concurrency, async (url) => {
119
+ try {
120
+ files[url] = { size: await probeFileSize(url, { fetch: fetcher, checkHost }) };
121
+ } catch (error) {
122
+ failed += 1;
123
+ const cached = cache.files[url];
124
+ if (cached?.size > 0) {
125
+ files[url] = cached;
126
+ log(`警告:读不到文件大小,沿用上次的结果:${url}(${error.message})`);
127
+ } else {
128
+ log(`警告:读不到文件大小,这个文件不显示大小:${url}(${error.message})`);
129
+ }
130
+ }
131
+ });
132
+ const sorted = Object.fromEntries(Object.keys(files).sort().map((url) => [url, files[url]]));
133
+ const next = `${JSON.stringify({ version: 1, files: sorted }, null, 2)}\n`;
134
+ const file = path.join(siteRoot, CACHE_FILE);
135
+ const previous = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
136
+ if (next !== previous && (urls.length || previous)) fs.writeFileSync(file, next);
137
+ log(`文件大小:${urls.length} 个远程文件,读到 ${urls.length - failed} 个,失败 ${failed} 个。`);
138
+ return { files: sorted, failed };
139
+ }
140
+
141
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
142
+ const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
143
+ await refreshFileSizes({ siteRoot, log: (line) => process.stdout.write(`${line}\n`) }).catch((error) => {
144
+ process.stdout.write(`警告:文件大小没有更新(${error.message}),继续构建。\n`);
145
+ });
146
+ }
@@ -21,6 +21,12 @@ const forbiddenFileNames = new Set([
21
21
  "wrangler.jsonc",
22
22
  ]);
23
23
  const forbiddenExtensions = new Set([".map", ".md", ".ts", ".tsx"]);
24
+ // The build copies into media/ only the files a post links to (images, garden file downloads):
25
+ // a Markdown file there is a download the author offers, not leaked source.
26
+ const publishedExtensions = { media: new Set([".md"]) };
27
+
28
+ const isPublishedFile = (relativePath) =>
29
+ publishedExtensions[relativePath.split("/", 1)[0]]?.has(path.extname(relativePath)) === true;
24
30
  const deploymentVersionPlaceholder = "00000000-0000-4000-8000-000000000000";
25
31
 
26
32
  function fail(message) {
@@ -55,7 +61,7 @@ async function inspectTree(root, directory = root) {
55
61
  if (forbiddenFileNames.has(entry.name)) {
56
62
  fail(`不允许源码配置:${relativePath}`);
57
63
  }
58
- if (entry.isFile() && forbiddenExtensions.has(path.extname(entry.name))) {
64
+ if (entry.isFile() && forbiddenExtensions.has(path.extname(entry.name)) && !isPublishedFile(relativePath)) {
59
65
  fail(`不允许源码或 Source Map:${relativePath}`);
60
66
  }
61
67
  if (entry.isDirectory()) await inspectTree(root, absolutePath);
@@ -20,47 +20,14 @@ import path from "node:path";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { parse as parseYaml } from "yaml";
22
22
  import { configuredSiteAddress } from "../ui/garden/same-site.mjs";
23
+ import { withTrailingSlash } from "../ui/garden/site-href.mjs";
24
+ import { resolveBrand } from "./brand.mjs";
23
25
 
24
26
  const escapeHtml = (value) => String(value).replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
25
27
 
26
- /** Valid `{ from, to }` pairs from the config; anything malformed is reported and skipped. */
27
- export function readRedirects(config = {}, warn = () => {}) {
28
- const map = config?.redirects;
29
- if (!map) return [];
30
- if (typeof map !== "object" || Array.isArray(map)) {
31
- warn("redirects 应写成「旧路径: 新地址」的映射,已忽略。");
32
- return [];
33
- }
34
- const pairs = [];
35
- for (const [rawFrom, rawTo] of Object.entries(map)) {
36
- let from = String(rawFrom).trim();
37
- const to = String(rawTo ?? "").trim();
38
- try {
39
- from = decodeURI(from);
40
- } catch {
41
- // keep as written
42
- }
43
- if (!from.startsWith("/") || from.startsWith("//") || from === "/" || /(^|\/)\.\.?(\/|$)/u.test(from) || /[?#]/u.test(from)) {
44
- warn(`redirects:旧路径 ${rawFrom} 必须是站内路径(以 / 开头,不带 ?、# 和 ..),已跳过。`);
45
- continue;
46
- }
47
- if (!(to.startsWith("/") && !to.startsWith("//")) && !/^https?:\/\//iu.test(to)) {
48
- warn(`redirects:${rawFrom} 的新地址 ${rawTo} 应以 / 开头或是完整网址,已跳过。`);
49
- continue;
50
- }
51
- pairs.push({ from, to });
52
- }
53
- return pairs;
54
- }
55
-
56
- /** `/columns/<slug>/` → `/columns/#<slug>` for every valid column slug in columns.yaml. */
57
- export function columnRedirects(columnsConfig = {}) {
58
- const columns = Array.isArray(columnsConfig?.columns) ? columnsConfig.columns : [];
59
- return columns
60
- .map((column) => (column && typeof column.slug === "string" ? column.slug.trim() : ""))
61
- .filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug))
62
- .map((slug) => ({ from: `/columns/${slug}/`, to: `/columns/#${slug}` }));
63
- }
28
+ // Shared with the article renderer, which rewrites links to old addresses (ui/garden/internal-links.mjs).
29
+ export { columnRedirects, readRedirects } from "../ui/garden/internal-links.mjs";
30
+ import { columnRedirects, readRedirects } from "../ui/garden/internal-links.mjs";
64
31
 
65
32
  /** The file an old path is served from: `/a/b/` and `/a/b` → a/b/index.html, `/a/b.html` → a/b.html. */
66
33
  export function redirectFile(from) {
@@ -68,13 +35,15 @@ export function redirectFile(from) {
68
35
  return /\.html?$/iu.test(clean) ? clean : path.posix.join(clean.replace(/\/+$/u, ""), "index.html");
69
36
  }
70
37
 
71
- export function redirectPage({ href, canonical }) {
38
+ export function redirectPage({ href, canonical, icon = "" }) {
39
+ // The site's own icon, so the browser does not go looking for /favicon.ico.
40
+ const iconLink = icon ? `\n<link rel="icon" href="${escapeHtml(icon)}"${/\.svg(?:[?#]|$)/iu.test(icon) ? ' type="image/svg+xml"' : ""}>` : "";
72
41
  return `<!doctype html>
73
42
  <html lang="zh-CN">
74
43
  <head>
75
44
  <meta charset="utf-8">
76
45
  <title>页面已移动</title>
77
- <link rel="canonical" href="${escapeHtml(canonical)}">
46
+ <link rel="canonical" href="${escapeHtml(canonical)}">${iconLink}
78
47
  <meta http-equiv="refresh" content="0; url=${escapeHtml(href)}">
79
48
  <script>location.replace(${JSON.stringify(href).replace(/</gu, "\\u003c")}${href.includes("#") ? "" : " + location.hash"})</script>
80
49
  </head>
@@ -86,11 +55,17 @@ export function redirectPage({ href, canonical }) {
86
55
  }
87
56
 
88
57
  /**
89
- * Write one page per redirect into `outDir`. A redirect is skipped, with a
90
- * warning, when a real page already lives at the old path or when a site path
91
- * it points to was not built. Never throws for a bad entry.
58
+ * Write one page per redirect into `outDir`. Returns what was written, how
59
+ * many pages the valid entries call for, and the entries that got no page.
60
+ *
61
+ * A malformed entry (not a site path, a bad target, the same old path twice)
62
+ * is left out with a warning and does not count. A valid entry is skipped,
63
+ * also with a warning, when a real page already lives at the old path or when
64
+ * the site path it points to was not built. Both are problems with that one
65
+ * entry, so they do not stop the build: `written` plus `skipped` makes up
66
+ * `expected`.
92
67
  */
93
- export function writeRedirects({ outDir, config = {}, env = {}, basePath = "", log = () => {}, extra = [] }) {
68
+ export function generateRedirects({ outDir, config = {}, env = {}, basePath = "", log = () => {}, extra = [], icon = "" }) {
94
69
  const base = String(basePath ?? "").replace(/\/+$/u, "");
95
70
  let origin = "";
96
71
  try {
@@ -100,15 +75,32 @@ export function writeRedirects({ outDir, config = {}, env = {}, basePath = "", l
100
75
  origin = "";
101
76
  }
102
77
  const written = [];
78
+ const skipped = [];
103
79
  const configured = readRedirects(config, log);
104
80
  // Configured redirects win over generated ones for the same old path.
105
81
  const taken = new Set(configured.map(({ from }) => redirectFile(from)));
106
- const pairs = [...configured, ...extra.filter(({ from }) => !taken.has(redirectFile(from)))];
82
+ const candidates = [...configured, ...extra.filter(({ from }) => !taken.has(redirectFile(from)))];
83
+ const seen = new Set();
84
+ const pairs = [];
85
+ for (const pair of candidates) {
86
+ const file = redirectFile(pair.from);
87
+ if (seen.has(file)) {
88
+ log(`redirects:${pair.from} 和前面的旧路径是同一个页面,已跳过。`);
89
+ continue;
90
+ }
91
+ seen.add(file);
92
+ pairs.push(pair);
93
+ }
107
94
  for (const { from, to } of pairs) {
108
95
  const file = path.join(outDir, redirectFile(from));
109
- if (!path.resolve(file).startsWith(path.resolve(outDir) + path.sep)) continue;
96
+ if (!path.resolve(file).startsWith(path.resolve(outDir) + path.sep)) {
97
+ log(`redirects:${from} 不在站点目录里,已跳过。`);
98
+ skipped.push({ from, to, reason: "outside" });
99
+ continue;
100
+ }
110
101
  if (fs.existsSync(file)) {
111
102
  log(`redirects:${from} 已经有页面,没有覆盖。`);
103
+ skipped.push({ from, to, reason: "page-exists" });
112
104
  continue;
113
105
  }
114
106
  const external = /^https?:\/\//iu.test(to);
@@ -118,30 +110,63 @@ export function writeRedirects({ outDir, config = {}, env = {}, basePath = "", l
118
110
  .some((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
119
111
  if (!built) {
120
112
  log(`redirects:${from} 指向的 ${to} 不存在,已跳过。`);
113
+ skipped.push({ from, to, reason: "missing-target" });
121
114
  continue;
122
115
  }
123
116
  }
124
- const href = external ? to : `${base}${to}`;
117
+ // A static export serves pages as folders: the target keeps the trailing slash like every site link.
118
+ const href = external ? to : withTrailingSlash(`${base}${to}`);
125
119
  const canonical = external || !origin ? href : `${origin}${href}`;
126
120
  fs.mkdirSync(path.dirname(file), { recursive: true });
127
- fs.writeFileSync(file, redirectPage({ href, canonical }));
121
+ fs.writeFileSync(file, redirectPage({ href, canonical, icon }));
128
122
  written.push({ from, to: href, file });
129
123
  }
130
- return written;
124
+ return { written, expected: pairs.length, skipped };
131
125
  }
132
126
 
133
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
134
- const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
127
+ /** The pages written by `generateRedirects`. Never throws for a bad entry. */
128
+ export function writeRedirects(options) {
129
+ return generateRedirects(options).written;
130
+ }
131
+
132
+ /**
133
+ * The build step. A bad entry, a missing target and a page already at the old
134
+ * path only print a warning. Exit code 1 when the step throws, or when the
135
+ * pages written are not the valid entries minus the ones skipped with a
136
+ * warning (a page went missing without a reason).
137
+ */
138
+ export function runRedirectStep({ siteRoot, env = process.env, out = process.stdout, err = process.stderr, generate = generateRedirects }) {
135
139
  const outDir = path.join(siteRoot, "dist", "client");
136
140
  try {
137
141
  const config = parseYaml(fs.readFileSync(path.join(siteRoot, "site.config.yaml"), "utf8")) ?? {};
138
142
  const columnsFile = path.join(siteRoot, "content", "columns.yaml");
139
143
  const extra = fs.existsSync(columnsFile) ? columnRedirects(parseYaml(fs.readFileSync(columnsFile, "utf8")) ?? {}) : [];
140
- if ((config.redirects || extra.length) && fs.existsSync(outDir)) {
141
- const written = writeRedirects({ outDir, config, env: process.env, basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? "", log: (line) => process.stdout.write(`警告:${line}\n`), extra });
142
- process.stdout.write(`旧地址跳转页:${written.length} 个。\n`);
144
+ if (!config.redirects && !extra.length) return 0;
145
+ if (!fs.existsSync(outDir)) throw new Error(`找不到构建产物 ${path.relative(siteRoot, outDir)}`);
146
+ const basePath = env.NEXT_PUBLIC_BASE_PATH ?? "";
147
+ const { written, expected, skipped } = generate({
148
+ outDir,
149
+ config,
150
+ env,
151
+ basePath,
152
+ icon: resolveBrand(siteRoot, config, basePath.replace(/\/+$/u, "")).favicon,
153
+ log: (line) => out.write(`警告:${line}\n`),
154
+ extra,
155
+ });
156
+ out.write(`旧地址跳转页:${written.length} 个${skipped.length ? `,跳过 ${skipped.length} 个(见上面的警告)` : ""}。\n`);
157
+ const due = expected - skipped.length;
158
+ if (written.length !== due) {
159
+ err.write(`错误:旧地址跳转页应生成 ${due} 个(${expected} 条有效配置,跳过 ${skipped.length} 条),实际生成了 ${written.length} 个,构建中止。\n`);
160
+ return 1;
143
161
  }
162
+ return 0;
144
163
  } catch (error) {
145
- process.stdout.write(`警告:旧地址跳转页没有生成(${error.message}),继续构建。\n`);
164
+ err.write(`错误:旧地址跳转页生成失败(${error.message}),构建中止。\n`);
165
+ return 1;
146
166
  }
147
167
  }
168
+
169
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
170
+ const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
171
+ process.exitCode = runRedirectStep({ siteRoot });
172
+ }
@@ -126,3 +126,14 @@ intent: |
126
126
  ---
127
127
  <State batch={1} />
128
128
  ```
129
+
130
+ ## file · 文件
131
+
132
+ 给读者一份可以下载的文件:规格包、PDF、示例代码。Markdown、HTML、PDF 还能在页面里预览。
133
+
134
+ 不该用:只是引用一个网页时用 card;要在正文里直接展示的图表或页面用 html。
135
+
136
+ ```garden file
137
+ src: ./post-name/spec.zip
138
+ note: 一句话说明里面有什么
139
+ ```
@@ -117,5 +117,14 @@ intent: |
117
117
  <key>shortcut</key>
118
118
  <string>;ginteractive</string>
119
119
  </dict>
120
+ <dict>
121
+ <key>phrase</key>
122
+ <string>```garden file
123
+ src: ./post-name/spec.zip
124
+ note: 一句话说明里面有什么
125
+ ```</string>
126
+ <key>shortcut</key>
127
+ <string>;gfile</string>
128
+ </dict>
120
129
  </array>
121
130
  </plist>
@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml";
3
3
  import { compileUiBlock, jsonFence, readPayload } from "./compile.mjs";
4
4
  import { findBlocks, scanFences } from "./fence.mjs";
5
5
  import { renderStatic } from "./static.mjs";
6
+ import { fileFeedMarkdown, fileSearchText } from "../garden/file-block.mjs";
6
7
 
7
8
  /**
8
9
  * Turns authored `ui` / `intent` fences into what the site renders.
@@ -59,6 +60,17 @@ function widgetCaption(body) {
59
60
  }
60
61
  }
61
62
 
63
+ /** Props of a `garden file` fence (authored or built), or null for any other fence. */
64
+ function fileFenceProps(fence) {
65
+ if (fence.lang !== "garden" || !/^file(?:\s|$)/iu.test(fence.info ?? "")) return null;
66
+ try {
67
+ const data = parseYaml(fence.body);
68
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
62
74
  function replaceFences(content, replacer) {
63
75
  let output = content;
64
76
  for (const fence of [...scanFences(content)].reverse()) {
@@ -94,6 +106,8 @@ export function contentToSearchText(content) {
94
106
  }
95
107
  if (fence.lang === "intent") return " ";
96
108
  if (fence.lang === "widget") return ` ${widgetCaption(fence.body).caption} `;
109
+ const file = fileFenceProps(fence);
110
+ if (file) return ` ${fileSearchText(file)} `;
97
111
  return undefined;
98
112
  });
99
113
  }
@@ -113,6 +127,8 @@ export function contentForFeed(content, { pageUrl = "", siteUrl = "" } = {}) {
113
127
  const href = absolute(src, siteUrl);
114
128
  return `${caption}${href ? `([打开图表](${href}))` : ""}`;
115
129
  }
130
+ const file = fileFenceProps(fence);
131
+ if (file) return fileFeedMarkdown(file, { siteUrl });
116
132
  return undefined;
117
133
  });
118
134
  }
@@ -132,6 +148,8 @@ export function contentForDistribution(content, { pageUrl = "", siteUrl = "" } =
132
148
  const href = absolute(src, siteUrl);
133
149
  return `${caption}${href ? `([打开图表](${href}))` : ""}`;
134
150
  }
151
+ const file = fileFenceProps(fence);
152
+ if (file) return fileFeedMarkdown(file, { siteUrl });
135
153
  return undefined;
136
154
  });
137
155
  }