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.
- package/app/a2/page.tsx +2 -1
- package/app/about/page.tsx +2 -1
- package/app/columns/[column]/[...path]/page.tsx +2 -1
- package/app/components/ArticleTags.tsx +6 -3
- package/app/components/BlogArchive.tsx +3 -2
- package/app/components/ContentEmptyState.tsx +2 -1
- package/app/components/MarkdownArticle.tsx +40 -3
- package/app/components/SiteHeader.tsx +18 -15
- package/app/components/TagRedirect.tsx +4 -3
- package/app/components/ThoughtCard.tsx +3 -2
- package/app/components/garden/FilePreview.tsx +176 -0
- package/app/components/garden/GardenBlock.tsx +4 -0
- package/app/components/garden/GardenFile.tsx +69 -0
- package/app/components/garden/file-icons.tsx +49 -0
- package/app/garden-modules.d.ts +1 -1
- package/app/layout.tsx +3 -0
- package/app/lib/content.ts +5 -3
- package/app/lib/markdown-plugins.ts +2 -11
- package/app/lib/site-href.ts +9 -0
- package/app/lib/tag-filter.ts +8 -0
- package/app/page.tsx +5 -4
- package/app/rss.xml/route.ts +6 -3
- package/app/sitemap.ts +4 -3
- package/app/styles/a2.css +72 -0
- package/app/styles/shell.css +21 -2
- package/app/tags/page.tsx +3 -2
- package/app/thoughts/[slug]/page.tsx +4 -3
- package/bin/garden.mjs +10 -2
- package/next.config.ts +3 -1
- package/package.json +3 -1
- package/scripts/build-content.mjs +110 -10
- package/scripts/check-post.mjs +45 -0
- package/scripts/file-preview.mjs +138 -0
- package/scripts/file-sizes.mjs +146 -0
- package/scripts/prepare-pages-output.mjs +7 -1
- package/scripts/redirects.mjs +80 -55
- package/templates/garden-blocks.md +11 -0
- package/templates/garden-snippets.plist +9 -0
- package/ui/core/transform.mjs +18 -0
- package/ui/garden/catalog.mjs +15 -1
- package/ui/garden/check.mjs +12 -0
- package/ui/garden/file-block.mjs +232 -0
- package/ui/garden/file-paths.mjs +65 -0
- package/ui/garden/hash-jump.mjs +37 -0
- package/ui/garden/internal-links.mjs +113 -0
- package/ui/garden/markdown-syntax.mjs +12 -0
- package/ui/garden/parse.mjs +1 -1
- package/ui/garden/same-site.mjs +27 -0
- package/ui/garden/site-href.mjs +62 -0
- package/ui/garden/site-nav.mjs +25 -0
- package/ui/garden/snippets.mjs +2 -2
- package/ui/garden/word-count.mjs +5 -0
- package/ui/preview/dist/garden-ui.esm.js +33 -33
- package/ui/preview/dist/garden-ui.iife.js +32 -32
package/ui/garden/catalog.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Names describe what the reader sees. English only.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export const GARDEN_NAMES = ["card", "stat", "compare", "timeline", "chart", "figure", "html", "interactive"];
|
|
7
|
+
export const GARDEN_NAMES = ["card", "stat", "compare", "timeline", "chart", "figure", "html", "interactive", "file"];
|
|
8
8
|
|
|
9
9
|
export const LEGACY_FENCES = {
|
|
10
10
|
embed: { name: "card", suggestion: "`embed` 已改为 `garden card`" },
|
|
@@ -132,6 +132,20 @@ intent: |
|
|
|
132
132
|
\`\`\``,
|
|
133
133
|
triggers: ["ginteractive"],
|
|
134
134
|
},
|
|
135
|
+
file: {
|
|
136
|
+
label: "文件",
|
|
137
|
+
when: "给读者一份可以下载的文件:规格包、PDF、示例代码。Markdown、HTML、PDF 还能在页面里预览。",
|
|
138
|
+
whenNot: "只是引用一个网页时用 card;要在正文里直接展示的图表或页面用 html。",
|
|
139
|
+
keys: ["src", "title", "note", "files"],
|
|
140
|
+
itemKeys: ["src", "title", "note"],
|
|
141
|
+
// Written by the build in place of the authored keys; never by hand.
|
|
142
|
+
builtKeys: ["resolved"],
|
|
143
|
+
example: `\`\`\`garden file
|
|
144
|
+
src: ./post-name/spec.zip
|
|
145
|
+
note: 一句话说明里面有什么
|
|
146
|
+
\`\`\``,
|
|
147
|
+
triggers: ["gfile"],
|
|
148
|
+
},
|
|
135
149
|
};
|
|
136
150
|
|
|
137
151
|
export const IDEA = {
|
package/ui/garden/check.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { isProseSteps, isTrivialInteractive } from "./enhance.mjs";
|
|
|
3
3
|
import { blanksBetween, classifyFence, isFullCodeFence, scanFences } from "./parse.mjs";
|
|
4
4
|
import { splitTitle } from "./title.mjs";
|
|
5
5
|
import { splitCompareLine } from "./compare-line.mjs";
|
|
6
|
+
import { readFileProps } from "./file-block.mjs";
|
|
6
7
|
|
|
7
8
|
const COMPARE_LIMIT = 60;
|
|
8
9
|
|
|
@@ -82,6 +83,17 @@ export function checkGardenMarkdown(markdown, { draft = false, title = "", title
|
|
|
82
83
|
push(fence.line, "garden-yaml", block.error.message, allowed.length ? `可用的键:${allowed.join("、")}` : "检查缩进和冒号。");
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
86
|
+
if (block.name === "file") {
|
|
87
|
+
if (block.props.resolved !== undefined) {
|
|
88
|
+
push(fence.line, "garden-file", "resolved 由构建生成,不要手写。", "删掉 resolved,只写 src、title、note 或 files。");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { errors } = readFileProps(block.props);
|
|
92
|
+
if (errors.length) {
|
|
93
|
+
push(fence.line, "garden-file", errors.join(";"), "单个文件写 src(可加 title、note);一组文件写 files 列表,每项一个 src。");
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
85
97
|
if (block.name === "compare" && compareTooLong(block.props)) {
|
|
86
98
|
push(fence.line, "compare-long", "对比格超过约 60 字。", "这一格改回原文的列表或段落。");
|
|
87
99
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `garden file`: downloadable files in a post.
|
|
3
|
+
*
|
|
4
|
+
* Authors write a short YAML fence:
|
|
5
|
+
*
|
|
6
|
+
* src: ./post-name/spec.zip one file
|
|
7
|
+
* title: 规格包 optional, defaults to the file name
|
|
8
|
+
* note: 一句话说明 optional
|
|
9
|
+
*
|
|
10
|
+
* or a group:
|
|
11
|
+
*
|
|
12
|
+
* title: 规格文档 optional
|
|
13
|
+
* files:
|
|
14
|
+
* - src: ./post-name/01-prd.md
|
|
15
|
+
* note: 产品定位与验收标准
|
|
16
|
+
*
|
|
17
|
+
* The build resolves every `src` next to the Markdown file, copies repo files
|
|
18
|
+
* to the public folder and replaces the fence with `resolved:` data (format,
|
|
19
|
+
* size, preview kind, public URL, and for Markdown the source to render). The
|
|
20
|
+
* page never parses Markdown in the browser.
|
|
21
|
+
*
|
|
22
|
+
* Everything here is plain data work shared by the build, `garden check`, the
|
|
23
|
+
* page and the tests.
|
|
24
|
+
*/
|
|
25
|
+
/** Repo files above this size get a `garden check` warning suggesting OSS. */
|
|
26
|
+
export const LARGE_FILE_BYTES = 5 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
export const FILE_KEYS = ["src", "title", "note", "files"];
|
|
29
|
+
export const FILE_ITEM_KEYS = ["src", "title", "note"];
|
|
30
|
+
|
|
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" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* HTML comments blanked out, line breaks kept, so fences inside a comment (the
|
|
47
|
+
* component cheat sheet in a new draft) are not mistaken for real blocks.
|
|
48
|
+
*/
|
|
49
|
+
export function maskHtmlComments(markdown) {
|
|
50
|
+
return String(markdown ?? "").replace(/<!--[\s\S]*?-->/gu, (comment) => comment.replace(/[^\n]/gu, " "));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extension of a path or URL, lower case, without the dot. */
|
|
54
|
+
export function fileExtension(src) {
|
|
55
|
+
const clean = String(src ?? "").split(/[?#]/u, 1)[0];
|
|
56
|
+
const base = clean.split("/").pop() ?? "";
|
|
57
|
+
const dot = base.lastIndexOf(".");
|
|
58
|
+
return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** File name shown on the card: the last path segment, decoded. */
|
|
62
|
+
export function fileName(src) {
|
|
63
|
+
const clean = String(src ?? "").split(/[?#]/u, 1)[0].replace(/\/+$/u, "");
|
|
64
|
+
const base = clean.split("/").pop() ?? "";
|
|
65
|
+
try {
|
|
66
|
+
return decodeURIComponent(base);
|
|
67
|
+
} catch {
|
|
68
|
+
return base;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Format label: Markdown, HTML, PDF, ZIP, or the extension in capitals. */
|
|
73
|
+
export function formatLabel(src) {
|
|
74
|
+
const ext = fileExtension(src);
|
|
75
|
+
return FORMATS[ext]?.label ?? (ext ? ext.toUpperCase() : "文件");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Icon family for the card: doc, code, archive or file. */
|
|
79
|
+
export function fileIcon(src) {
|
|
80
|
+
return FORMATS[fileExtension(src)]?.icon ?? "file";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const isRemoteSrc = (src) => /^https?:\/\//iu.test(String(src ?? "").trim());
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How the drawer previews a file: "md", "html", "pdf", or "" for download only.
|
|
87
|
+
* Remote files have no preview in this version.
|
|
88
|
+
*/
|
|
89
|
+
export function previewKind(src, { remote = isRemoteSrc(src) } = {}) {
|
|
90
|
+
if (remote) return "";
|
|
91
|
+
return FORMATS[fileExtension(src)]?.preview ?? "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sandbox for an HTML preview. Scripts may run so prototypes work, but the
|
|
96
|
+
* page never gets the site's origin: no allow-same-origin.
|
|
97
|
+
*/
|
|
98
|
+
export const HTML_PREVIEW_SANDBOX = "allow-scripts allow-popups allow-forms";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Attributes of the drawer's iframe, or null for a Markdown preview (rendered
|
|
102
|
+
* on the server, no frame). HTML gets the sandbox; PDF gets none, because the
|
|
103
|
+
* browser's PDF viewer does not run inside a sandboxed frame.
|
|
104
|
+
*/
|
|
105
|
+
export function previewFrameProps(file) {
|
|
106
|
+
const kind = file?.preview;
|
|
107
|
+
if (kind !== "html" && kind !== "pdf") return null;
|
|
108
|
+
const props = { src: String(file.url ?? ""), title: `预览 ${file.name ?? ""}`.trim(), referrerPolicy: "no-referrer" };
|
|
109
|
+
return kind === "html" ? { ...props, sandbox: HTML_PREVIEW_SANDBOX } : props;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Only web and site paths become links; anything else (javascript:, data:, //host) is dropped. */
|
|
113
|
+
export function safeFileHref(url) {
|
|
114
|
+
const value = typeof url === "string" ? url.trim() : "";
|
|
115
|
+
if (/^https?:\/\//iu.test(value)) return value;
|
|
116
|
+
if (/^[a-z][a-z\d+.-]*:/iu.test(value) || value.startsWith("//")) return "";
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 12 KB, 1.4 MB: one decimal below 10, whole numbers above. */
|
|
121
|
+
export function formatSize(bytes) {
|
|
122
|
+
const value = Number(bytes);
|
|
123
|
+
if (!Number.isFinite(value) || value < 0) return "";
|
|
124
|
+
if (value < 1024) return `${Math.round(value)} B`;
|
|
125
|
+
const units = ["KB", "MB", "GB"];
|
|
126
|
+
let size = value / 1024;
|
|
127
|
+
let unit = 0;
|
|
128
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
129
|
+
size /= 1024;
|
|
130
|
+
unit += 1;
|
|
131
|
+
}
|
|
132
|
+
const text = size < 10 ? size.toFixed(1).replace(/\.0$/u, "") : String(Math.round(size));
|
|
133
|
+
return `${text} ${units[unit]}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 「Markdown · 12 KB」, or just the format when the size is unknown. */
|
|
137
|
+
export function fileMeta(file) {
|
|
138
|
+
const size = formatSize(file?.size);
|
|
139
|
+
return [file?.format, size].filter(Boolean).join(" · ");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const text = (value) => (typeof value === "string" ? value.trim() : value === undefined || value === null ? "" : String(value).trim());
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validates authored props. Returns `{ layout, title, files, errors }` where
|
|
146
|
+
* `layout` is "single" for one `src`, "group" for a `files` list.
|
|
147
|
+
*/
|
|
148
|
+
export function readFileProps(props) {
|
|
149
|
+
const errors = [];
|
|
150
|
+
const data = props && typeof props === "object" && !Array.isArray(props) ? props : {};
|
|
151
|
+
const item = (raw, where) => {
|
|
152
|
+
if (typeof raw === "string") return { src: raw.trim(), title: "", note: "" };
|
|
153
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
154
|
+
errors.push(`${where} 要写成带 src 的一项`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const unknown = Object.keys(raw).filter((key) => !FILE_ITEM_KEYS.includes(key));
|
|
158
|
+
if (unknown.length) errors.push(`${where} 不支持的键:${unknown.join("、")}`);
|
|
159
|
+
const src = text(raw.src);
|
|
160
|
+
if (!src) errors.push(`${where} 缺少 src`);
|
|
161
|
+
return { src, title: text(raw.title), note: text(raw.note) };
|
|
162
|
+
};
|
|
163
|
+
if (data.files !== undefined) {
|
|
164
|
+
if (data.src !== undefined || data.note !== undefined) errors.push("files 和 src / note 不能同时写:一组文件的说明写在每一项里");
|
|
165
|
+
if (!Array.isArray(data.files) || !data.files.length) {
|
|
166
|
+
errors.push("files 要写成至少一项的列表");
|
|
167
|
+
return { layout: "group", title: text(data.title), files: [], errors };
|
|
168
|
+
}
|
|
169
|
+
const files = data.files.map((raw, index) => item(raw, `files 第 ${index + 1} 项`)).filter(Boolean);
|
|
170
|
+
return { layout: "group", title: text(data.title), files, errors };
|
|
171
|
+
}
|
|
172
|
+
const single = item({ src: data.src, title: data.title, note: data.note }, "文件");
|
|
173
|
+
return { layout: "single", title: "", files: single ? [single] : [], errors };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Files of a built or authored block, whichever the fence holds. */
|
|
177
|
+
export function blockFiles(props) {
|
|
178
|
+
if (props?.resolved && Array.isArray(props.resolved.files)) return props.resolved;
|
|
179
|
+
const read = readFileProps(props);
|
|
180
|
+
return {
|
|
181
|
+
layout: read.layout,
|
|
182
|
+
title: read.title,
|
|
183
|
+
files: read.files.map((file) => ({
|
|
184
|
+
...file,
|
|
185
|
+
name: fileName(file.src),
|
|
186
|
+
title: file.title || fileName(file.src),
|
|
187
|
+
url: file.src,
|
|
188
|
+
format: formatLabel(file.src),
|
|
189
|
+
icon: fileIcon(file.src),
|
|
190
|
+
remote: isRemoteSrc(file.src),
|
|
191
|
+
preview: "",
|
|
192
|
+
})),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Words a reader sees on the card: group title, file titles and notes. */
|
|
197
|
+
export function fileSearchText(props) {
|
|
198
|
+
const block = blockFiles(props);
|
|
199
|
+
return [block.title, ...block.files.flatMap((file) => [file.title || file.name, file.note])]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(" ");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const absolute = (url, siteUrl) => {
|
|
205
|
+
if (!siteUrl || /^[a-z][a-z\d+.-]*:/iu.test(url)) return url;
|
|
206
|
+
try {
|
|
207
|
+
return new URL(url, `${siteUrl.replace(/\/+$/u, "")}/`).toString();
|
|
208
|
+
} catch {
|
|
209
|
+
return url;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Plain text inside a Markdown link: one line, every character that could start markup escaped.
|
|
214
|
+
const mdText = (value) => String(value ?? "").replace(/\s+/gu, " ").trim().replace(/([\\[\]()*_`<>!~|])/gu, "\\$1");
|
|
215
|
+
|
|
216
|
+
/** RSS and distribution: a plain list of download links. */
|
|
217
|
+
export function fileFeedMarkdown(props, { siteUrl = "" } = {}) {
|
|
218
|
+
const block = blockFiles(props);
|
|
219
|
+
const lines = block.files.map((file) => {
|
|
220
|
+
const meta = fileMeta(file);
|
|
221
|
+
const note = file.note ? `:${mdText(file.note)}` : "";
|
|
222
|
+
const href = absolute(file.url, siteUrl).replace(/[<>\s]/gu, (char) => encodeURIComponent(char));
|
|
223
|
+
return `- [${mdText(file.title || file.name)}](<${href}>)${meta ? `(${meta})` : ""}${note}`;
|
|
224
|
+
});
|
|
225
|
+
return [block.title ? `**${mdText(block.title)}**\n` : "", ...lines].filter(Boolean).join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The HTML inside a published preview's <main> (what the drawer shows; see scripts/file-preview.mjs). */
|
|
229
|
+
export function previewBody(documentHtml) {
|
|
230
|
+
const match = /<main class="gf-preview-doc">\n?([\s\S]*?)\n?<\/main>/u.exec(String(documentHtml));
|
|
231
|
+
return match ? match[1] : "";
|
|
232
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Node only: where a `garden file` src points on disk. Kept apart from
|
|
2
|
+
// file-block.mjs so the page bundle never pulls in node:path.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isRemoteSrc, LARGE_FILE_BYTES, formatSize, maskHtmlComments, readFileProps } from "./file-block.mjs";
|
|
6
|
+
import { parseGardenBlock, parseGardenInfo, scanFences } from "./parse.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Absolute path of a repo file named by a relative `src`, resolved next to the
|
|
10
|
+
* Markdown file.
|
|
11
|
+
* Returns `{ path }` or `{ error }` when it leaves the content root.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveRepoFile(src, markdownPath, contentRoot) {
|
|
14
|
+
const [assetPath] = String(src ?? "").trim().replace(/^<|>$/gu, "").split(/(?=[?#])/u, 1);
|
|
15
|
+
let decoded = assetPath;
|
|
16
|
+
try {
|
|
17
|
+
decoded = decodeURIComponent(assetPath);
|
|
18
|
+
} catch {
|
|
19
|
+
// Keep the raw path; a missing file is reported by the caller.
|
|
20
|
+
}
|
|
21
|
+
const absolute = path.resolve(path.dirname(markdownPath), decoded);
|
|
22
|
+
const relative = path.relative(contentRoot, absolute);
|
|
23
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
24
|
+
return { error: `文件必须放在 content 目录里:${src}` };
|
|
25
|
+
}
|
|
26
|
+
return { path: absolute };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `garden check` for the files a post links: every repo file must exist
|
|
31
|
+
* inside content/, and one above 5 MB gets a warning (Git keeps every version
|
|
32
|
+
* forever; object storage suits big files). Remote URLs are not fetched here.
|
|
33
|
+
* `line` is 1-based in `markdown`.
|
|
34
|
+
*/
|
|
35
|
+
export function checkFileSources(markdown, markdownPath, contentRoot) {
|
|
36
|
+
const diagnostics = [];
|
|
37
|
+
for (const fence of scanFences(maskHtmlComments(markdown))) {
|
|
38
|
+
if (parseGardenInfo(fence.info)?.name !== "file") continue;
|
|
39
|
+
const block = parseGardenBlock(fence.info, fence.body);
|
|
40
|
+
if (!block || block.error || block.props.resolved !== undefined) continue;
|
|
41
|
+
for (const file of readFileProps(block.props).files) {
|
|
42
|
+
if (!file.src || isRemoteSrc(file.src) || file.src.startsWith("/")) continue;
|
|
43
|
+
const resolved = resolveRepoFile(file.src, markdownPath, contentRoot);
|
|
44
|
+
if (resolved.error) {
|
|
45
|
+
diagnostics.push({ line: fence.line, code: "file-outside", severity: "error", message: resolved.error, suggestion: `把文件放进同名目录,例如 ./${path.basename(markdownPath, ".md")}/文件名` });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!fs.existsSync(resolved.path) || !fs.statSync(resolved.path).isFile()) {
|
|
49
|
+
diagnostics.push({ line: fence.line, code: "missing-asset", severity: "error", message: `找不到文件 ${file.src}`, suggestion: "src 相对这篇 Markdown 所在的目录。" });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const size = fs.statSync(resolved.path).size;
|
|
53
|
+
if (size > LARGE_FILE_BYTES) {
|
|
54
|
+
diagnostics.push({
|
|
55
|
+
line: fence.line,
|
|
56
|
+
code: "file-large",
|
|
57
|
+
severity: "warning",
|
|
58
|
+
message: `${file.src} 有 ${formatSize(size)},超过 5 MB。`,
|
|
59
|
+
suggestion: "大文件放到对象存储(如阿里云 OSS),src 写完整的 https 地址;Git 会永久保存每个版本。",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return diagnostics;
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page `#` links without a page refetch.
|
|
3
|
+
*
|
|
4
|
+
* Following a plain `<a href="#section">` is a fragment navigation: the
|
|
5
|
+
* browser scrolls, adds a history entry with no state and fires `popstate`.
|
|
6
|
+
* The router (vinext 0.0.50) treats every `popstate` as a history traversal
|
|
7
|
+
* and fetches the page's RSC payload again, although only the hash changed.
|
|
8
|
+
* Next.js itself ignores a popstate without state.
|
|
9
|
+
*
|
|
10
|
+
* The guard notes a click on a same-page hash link that nothing else handled,
|
|
11
|
+
* and swallows the stateless `popstate` that follows it before the router
|
|
12
|
+
* sees it. On `window`, listeners run in the order they were added, capture
|
|
13
|
+
* or not, so this has to be added before the router's: it ships as a small
|
|
14
|
+
* inline script in <head>, which runs before the deferred module scripts.
|
|
15
|
+
* The click listener sits on `window`, after React's handlers on the
|
|
16
|
+
* document, so a link that the TOC or a Link already handled is left alone.
|
|
17
|
+
* Back and forward between real history entries still reach the router.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** How long after the click the fragment's popstate may arrive. */
|
|
21
|
+
export const HASH_JUMP_WINDOW_MS = 1000;
|
|
22
|
+
|
|
23
|
+
export const HASH_JUMP_SCRIPT = `(function(w){var armed=-Infinity;\
|
|
24
|
+
w.addEventListener("click",function(e){\
|
|
25
|
+
if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;\
|
|
26
|
+
var a=e.target&&e.target.closest&&e.target.closest("a[href]");\
|
|
27
|
+
if(!a||a.hasAttribute("download"))return;\
|
|
28
|
+
var t=a.getAttribute("target");if(t&&t!=="_self")return;\
|
|
29
|
+
var h=a.getAttribute("href")||"";if(h.indexOf("#")<0)return;\
|
|
30
|
+
var u,l=w.location;try{u=new URL(h,l.href)}catch(x){return}\
|
|
31
|
+
if(u.origin===l.origin&&u.pathname===l.pathname&&u.search===l.search&&u.hash.length>1)armed=Date.now();\
|
|
32
|
+
});\
|
|
33
|
+
w.addEventListener("popstate",function(e){\
|
|
34
|
+
var on=Date.now()-armed<=${HASH_JUMP_WINDOW_MS};armed=-Infinity;\
|
|
35
|
+
if(on&&e.state==null)e.stopImmediatePropagation();\
|
|
36
|
+
},true);\
|
|
37
|
+
})(window);`;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-site links in article bodies.
|
|
3
|
+
*
|
|
4
|
+
* `redirects` in site.config.yaml maps old addresses to where pages live now,
|
|
5
|
+
* and a static export answers the old address with a small meta-refresh page
|
|
6
|
+
* (scripts/redirects.mjs). A client navigation cannot follow that page: there
|
|
7
|
+
* is no RSC payload at the old address. So when an article links to an old
|
|
8
|
+
* address, the rendered link points straight at the new one, keeping the
|
|
9
|
+
* link's own ?query and #hash.
|
|
10
|
+
*
|
|
11
|
+
* `internalRoute` returns the address to give the framework Link, or null to
|
|
12
|
+
* keep a plain anchor: a link that leaves the site, ends on a file, runs into a
|
|
13
|
+
* redirect loop or an external target, or lands on no known page.
|
|
14
|
+
*/
|
|
15
|
+
import { clientRoute } from "./same-site.mjs";
|
|
16
|
+
|
|
17
|
+
/** Valid `{ from, to }` pairs from the config; anything malformed is reported and skipped. */
|
|
18
|
+
export function readRedirects(config = {}, warn = () => {}) {
|
|
19
|
+
const map = config?.redirects;
|
|
20
|
+
if (!map) return [];
|
|
21
|
+
if (typeof map !== "object" || Array.isArray(map)) {
|
|
22
|
+
warn("redirects 应写成「旧路径: 新地址」的映射,已忽略。");
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const pairs = [];
|
|
26
|
+
for (const [rawFrom, rawTo] of Object.entries(map)) {
|
|
27
|
+
let from = String(rawFrom).trim();
|
|
28
|
+
const to = String(rawTo ?? "").trim();
|
|
29
|
+
try {
|
|
30
|
+
from = decodeURI(from);
|
|
31
|
+
} catch {
|
|
32
|
+
// keep as written
|
|
33
|
+
}
|
|
34
|
+
if (!from.startsWith("/") || from.startsWith("//") || from === "/" || /(^|\/)\.\.?(\/|$)/u.test(from) || /[?#]/u.test(from)) {
|
|
35
|
+
warn(`redirects:旧路径 ${rawFrom} 必须是站内路径(以 / 开头,不带 ?、# 和 ..),已跳过。`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!(to.startsWith("/") && !to.startsWith("//")) && !/^https?:\/\//iu.test(to)) {
|
|
39
|
+
warn(`redirects:${rawFrom} 的新地址 ${rawTo} 应以 / 开头或是完整网址,已跳过。`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
pairs.push({ from, to });
|
|
43
|
+
}
|
|
44
|
+
return pairs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `/columns/<slug>/` → `/columns/#<slug>` for every valid column slug in columns.yaml. */
|
|
48
|
+
export function columnRedirects(columnsConfig = {}) {
|
|
49
|
+
const columns = Array.isArray(columnsConfig?.columns) ? columnsConfig.columns : [];
|
|
50
|
+
return columns
|
|
51
|
+
.map((column) => (column && typeof column.slug === "string" ? column.slug.trim() : ""))
|
|
52
|
+
.filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug))
|
|
53
|
+
.map((slug) => ({ from: `/columns/${slug}/`, to: `/columns/#${slug}` }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One key per page address: decoded, without a trailing slash (`/` stays `/`). */
|
|
57
|
+
export function routeKey(pathname) {
|
|
58
|
+
let value = String(pathname ?? "");
|
|
59
|
+
try {
|
|
60
|
+
value = decodeURI(value);
|
|
61
|
+
} catch {
|
|
62
|
+
// keep as written
|
|
63
|
+
}
|
|
64
|
+
return value.length > 1 ? value.replace(/\/+$/u, "") || "/" : value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const splitHref = (href) => {
|
|
68
|
+
const hashAt = href.indexOf("#");
|
|
69
|
+
const beforeHash = hashAt < 0 ? href : href.slice(0, hashAt);
|
|
70
|
+
const hash = hashAt < 0 ? "" : href.slice(hashAt);
|
|
71
|
+
const queryAt = beforeHash.indexOf("?");
|
|
72
|
+
return { pathname: queryAt < 0 ? beforeHash : beforeHash.slice(0, queryAt), search: queryAt < 0 ? "" : beforeHash.slice(queryAt), hash };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the resolver once per render.
|
|
77
|
+
* - redirects: `{ from, to }` pairs (readRedirects + columnRedirects).
|
|
78
|
+
* - routes: every page address the site builds, without the base path.
|
|
79
|
+
* - basePath: NEXT_PUBLIC_BASE_PATH; Link adds it back.
|
|
80
|
+
* - format: shapes the route it returns (the app passes siteHref, which adds the trailing slash).
|
|
81
|
+
*/
|
|
82
|
+
export function internalLinkResolver({ redirects = [], routes = [], basePath = "", format = (href) => href } = {}) {
|
|
83
|
+
const moved = new Map();
|
|
84
|
+
for (const { from, to } of redirects) if (!moved.has(routeKey(from))) moved.set(routeKey(from), to);
|
|
85
|
+
const pages = new Set([...routes].map(routeKey));
|
|
86
|
+
const base = String(basePath ?? "").trim().replace(/\/+$/u, "");
|
|
87
|
+
return function internalRoute(href) {
|
|
88
|
+
if (typeof href !== "string") return null;
|
|
89
|
+
let value = href.trim();
|
|
90
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
91
|
+
if (base) {
|
|
92
|
+
if (value !== base && !value.startsWith(`${base}/`) && !value.startsWith(`${base}?`) && !value.startsWith(`${base}#`)) return null;
|
|
93
|
+
value = value.slice(base.length) || "/";
|
|
94
|
+
if (!value.startsWith("/")) value = `/${value}`;
|
|
95
|
+
}
|
|
96
|
+
const own = splitHref(value);
|
|
97
|
+
let target = own;
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
while (moved.has(routeKey(target.pathname))) {
|
|
100
|
+
const key = routeKey(target.pathname);
|
|
101
|
+
if (seen.has(key) || seen.size > 16) return null;
|
|
102
|
+
seen.add(key);
|
|
103
|
+
const to = moved.get(key);
|
|
104
|
+
if (!to.startsWith("/") || to.startsWith("//")) return null;
|
|
105
|
+
const next = splitHref(to);
|
|
106
|
+
// The link's own ?query and #hash win; otherwise the redirect target's.
|
|
107
|
+
target = { pathname: next.pathname || "/", search: own.search || next.search, hash: own.hash || next.hash };
|
|
108
|
+
}
|
|
109
|
+
if (!pages.has(routeKey(target.pathname))) return null;
|
|
110
|
+
const route = clientRoute(`${target.pathname}${target.search}${target.hash}`);
|
|
111
|
+
return route ? format(route) : route;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import remarkBreaks from "remark-breaks";
|
|
2
|
+
import remarkCjkFriendly from "remark-cjk-friendly";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkMath from "remark-math";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Markdown syntax every post is read with: GFM, CJK-friendly emphasis,
|
|
8
|
+
* hard line breaks and math. Posts add remarkGarden on top
|
|
9
|
+
* (app/lib/markdown-plugins.ts); a previewed Markdown file uses this list as
|
|
10
|
+
* is (scripts/file-preview.mjs), so both read the same Markdown the same way.
|
|
11
|
+
*/
|
|
12
|
+
export const markdownSyntaxPlugins = [remarkGfm, remarkCjkFriendly, remarkBreaks, remarkMath];
|
package/ui/garden/parse.mjs
CHANGED
|
@@ -137,7 +137,7 @@ export function parseGardenBlock(info, body) {
|
|
|
137
137
|
error: { code: "garden-name", message: `未知名字 ${header.name}` },
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
|
-
const unknown = Object.keys(props).filter((key) => key !== "id" && !spec.keys.includes(key));
|
|
140
|
+
const unknown = Object.keys(props).filter((key) => key !== "id" && !spec.keys.includes(key) && !spec.builtKeys?.includes(key));
|
|
141
141
|
if (unknown.length) {
|
|
142
142
|
return {
|
|
143
143
|
name: header.name,
|
package/ui/garden/same-site.mjs
CHANGED
|
@@ -61,3 +61,30 @@ export function articleLink(href, sameSite) {
|
|
|
61
61
|
if (local !== null) return { href: local, external: false };
|
|
62
62
|
return { href, external: /^(https?:)?\/\//iu.test(href) };
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/** Static files the site serves next to its pages; these always load as files. */
|
|
66
|
+
const FILE_PREFIXES = ["/api/", "/assets/", "/images/", "/media/", "/link-assets/", "/_next/", "/fonts/"];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The route to hand to the framework Link for an in-article link, or null to
|
|
70
|
+
* keep a plain anchor. Only a root-relative page address qualifies: no
|
|
71
|
+
* protocol or host, not just a hash, no file extension in the last segment,
|
|
72
|
+
* none of the static file folders. Link adds the base path itself, so it is
|
|
73
|
+
* taken off here; a path outside the base path stays a plain anchor.
|
|
74
|
+
*/
|
|
75
|
+
export function clientRoute(href, basePath = "") {
|
|
76
|
+
if (typeof href !== "string") return null;
|
|
77
|
+
const value = href.trim();
|
|
78
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
79
|
+
const base = normalizeBase(basePath);
|
|
80
|
+
const [pathname] = value.split(/[?#]/u);
|
|
81
|
+
let route = pathname;
|
|
82
|
+
if (base) {
|
|
83
|
+
if (route !== base && !route.startsWith(`${base}/`)) return null;
|
|
84
|
+
route = route.slice(base.length) || "/";
|
|
85
|
+
}
|
|
86
|
+
if (FILE_PREFIXES.some((prefix) => route.startsWith(prefix) || `${route}/` === prefix)) return null;
|
|
87
|
+
const last = route.split("/").filter(Boolean).at(-1) ?? "";
|
|
88
|
+
if (/\.[a-z0-9]{1,8}$/iu.test(last)) return null;
|
|
89
|
+
return `${route}${value.slice(pathname.length)}`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether page addresses end with a slash: Next's `trailingSlash`.
|
|
3
|
+
*
|
|
4
|
+
* A static export always uses it: every page is a folder (`/blog/post/`) and
|
|
5
|
+
* the host answers `/blog/post` with a 301 first. A server build uses it only
|
|
6
|
+
* with TRAILING_SLASH=1; without it the server answers `/blog/post/` with a
|
|
7
|
+
* 308 to `/blog/post`, so links must not carry the slash there.
|
|
8
|
+
* next.config.ts and the generated siteLinks both read this.
|
|
9
|
+
*/
|
|
10
|
+
export function trailingSlashEnabled(env = process.env) {
|
|
11
|
+
return env.STATIC_EXPORT === "1" || env.TRAILING_SLASH === "1";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The one place that shapes an address inside the site.
|
|
16
|
+
*
|
|
17
|
+
* With `trailing` (the default) every page path ends with a slash; without it
|
|
18
|
+
* the slash is dropped (the root stays `/`). `?query` and `#hash` come after
|
|
19
|
+
* the path either way. Files (a last segment with an extension), pure `#hash`
|
|
20
|
+
* or `?query` links and anything outside the site are left alone.
|
|
21
|
+
*/
|
|
22
|
+
export function withTrailingSlash(href, trailing = true) {
|
|
23
|
+
if (typeof href !== "string") return href;
|
|
24
|
+
const value = href.trim();
|
|
25
|
+
if (!value.startsWith("/") || value.startsWith("//")) return value;
|
|
26
|
+
const hashAt = value.indexOf("#");
|
|
27
|
+
const beforeHash = hashAt < 0 ? value : value.slice(0, hashAt);
|
|
28
|
+
const hash = hashAt < 0 ? "" : value.slice(hashAt);
|
|
29
|
+
const queryAt = beforeHash.indexOf("?");
|
|
30
|
+
let pathname = queryAt < 0 ? beforeHash : beforeHash.slice(0, queryAt);
|
|
31
|
+
const search = queryAt < 0 ? "" : beforeHash.slice(queryAt);
|
|
32
|
+
const last = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
|
33
|
+
if (/\.[a-z0-9]{1,8}$/iu.test(last)) return value;
|
|
34
|
+
if (trailing) {
|
|
35
|
+
if (!pathname.endsWith("/")) pathname = `${pathname}/`;
|
|
36
|
+
} else if (pathname.length > 1) {
|
|
37
|
+
pathname = pathname.replace(/\/+$/u, "") || "/";
|
|
38
|
+
}
|
|
39
|
+
return `${pathname}${search}${hash}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const decodeAttribute = (value) =>
|
|
43
|
+
value.replace(/&/gu, "&").replace(/"/gu, '"').replace(/'|'/gu, "'").replace(/</gu, "<").replace(/>/gu, ">");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Links in a built page whose site path has the wrong shape for the build:
|
|
47
|
+
* `<a>` and `<area>` hrefs that start with one `/` and lack the trailing slash
|
|
48
|
+
* (`trailing`, a static export) or carry one (a server build without it).
|
|
49
|
+
* Files (an extension in the last segment) and pure #hash links are fine.
|
|
50
|
+
*/
|
|
51
|
+
export function findMisshapenLinks(html, { trailing = true } = {}) {
|
|
52
|
+
const found = [];
|
|
53
|
+
for (const match of String(html ?? "").matchAll(/<(?:a|area)\b[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/giu)) {
|
|
54
|
+
const href = decodeAttribute(match[1] ?? match[2] ?? "").trim();
|
|
55
|
+
if (!href.startsWith("/") || href.startsWith("//")) continue;
|
|
56
|
+
if (withTrailingSlash(href, trailing) !== href) found.push(href);
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Internal page links without the trailing slash (checks a static export). */
|
|
62
|
+
export const findSlashlessLinks = (html) => findMisshapenLinks(html, { trailing: true });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header's site navigation. Hrefs carry the trailing slash: a static
|
|
3
|
+
* export serves these pages at /blog/ and so on, and the host answers /blog
|
|
4
|
+
* with a 301 first (new tab, no JavaScript, crawlers).
|
|
5
|
+
*/
|
|
6
|
+
export const SITE_NAV = [
|
|
7
|
+
{ href: "/blog/", label: "文章" },
|
|
8
|
+
{ href: "/thoughts/", label: "随想" },
|
|
9
|
+
{ href: "/columns/", label: "专栏" },
|
|
10
|
+
{ href: "/tags/", label: "标签" },
|
|
11
|
+
{ href: "/about/", label: "关于" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const trim = (value) => (value.length > 1 ? value.replace(/\/+$/u, "") : value);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a nav item is the current section. The pathname may come with or
|
|
18
|
+
* without the trailing slash; articles count as 文章 (/blog/…, /a2/…).
|
|
19
|
+
*/
|
|
20
|
+
export function isCurrentNav(pathname, href) {
|
|
21
|
+
const path = trim(pathname || "/");
|
|
22
|
+
const section = trim(href);
|
|
23
|
+
if (section === "/blog") return path === "/blog" || path.startsWith("/blog/") || path.startsWith("/a2/");
|
|
24
|
+
return path === section || path.startsWith(`${section}/`);
|
|
25
|
+
}
|
package/ui/garden/snippets.mjs
CHANGED
|
@@ -15,8 +15,8 @@ export function vscodeSnippets() {
|
|
|
15
15
|
const snippets = {
|
|
16
16
|
"Garden 块": {
|
|
17
17
|
prefix: "garden",
|
|
18
|
-
body: [
|
|
19
|
-
description:
|
|
18
|
+
body: [`\`\`\`garden \${1|${["idea", ...GARDEN_NAMES].join(",")}|}`, "$0", "```"],
|
|
19
|
+
description: `选择 Garden 块:想法 / ${GARDEN_NAMES.join(" / ")}`,
|
|
20
20
|
},
|
|
21
21
|
};
|
|
22
22
|
for (const trigger of IDEA.triggers) {
|