hd-garden 0.5.1 → 0.5.3

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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The site's logo and icons, from `brand` in site.config.yaml:
3
+ *
4
+ * brand:
5
+ * logo: brand/logo.svg # SVG file in the site, shown before the name; false hides it
6
+ * favicon: /favicon.svg # URL under public/
7
+ * appleTouchIcon: /apple-touch-icon.png
8
+ *
9
+ * The logo is inlined so it follows the text colour: draw it with
10
+ * `stroke="currentColor"` or `fill="currentColor"`.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ // Garden's own mark: a leaf on an h, cropped to its strokes.
16
+ export const DEFAULT_LOGO = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 2 18 27" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 27V10"/><path d="M10 10C10 5.5 13.5 3.5 18 3.5C18 7.5 15 10 10 10Z"/><path d="M10 19C10 15.5 12.5 13.5 15.5 13.5S21 15.5 21 19V27"/></svg>';
17
+
18
+ /** Keep only the `<svg>` element, without scripts, event handlers or external references. */
19
+ export function cleanSvg(markup) {
20
+ const match = String(markup ?? "").match(/<svg\b[\s\S]*<\/svg>/iu);
21
+ if (!match) return "";
22
+ return match[0]
23
+ .replace(/<!--[\s\S]*?-->/gu, "")
24
+ .replace(/<(script|foreignObject)\b[\s\S]*?<\/\1>/giu, "")
25
+ .replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/giu, "")
26
+ .replace(/\s+(?:xlink:)?href\s*=\s*("[^"#][^"]*"|'[^'#][^']*')/giu, "")
27
+ // The header sets the size; the file's own width and height would fight it.
28
+ .replace(/^<svg\b([^>]*)>/iu, (whole, attrs) => `<svg${attrs.replace(/\s(width|height)\s*=\s*("[^"]*"|'[^']*')/giu, "")} aria-hidden="true" focusable="false">`)
29
+ .replace(/\s*\n\s*/gu, "");
30
+ }
31
+
32
+ const publicUrl = (value, basePath) => {
33
+ const url = String(value);
34
+ if (/^(https?:)?\/\//iu.test(url) || url.startsWith("data:")) return url;
35
+ return `${basePath}${url.startsWith("/") ? url : `/${url}`}`;
36
+ };
37
+
38
+ export function resolveBrand(siteRoot, config = {}, basePath = "", warn = () => {}) {
39
+ const brand = config?.brand ?? {};
40
+ let logo = "";
41
+ if (brand.logo !== false && brand.logo !== "none") {
42
+ let markup = DEFAULT_LOGO;
43
+ if (typeof brand.logo === "string" && brand.logo.trim()) {
44
+ const resolved = path.resolve(siteRoot, brand.logo.replace(/^\//u, ""));
45
+ if (!resolved.startsWith(path.resolve(siteRoot) + path.sep) || !fs.existsSync(resolved)) {
46
+ warn(`brand.logo 找不到文件 ${brand.logo},使用默认图标。`);
47
+ } else {
48
+ markup = fs.readFileSync(resolved, "utf8");
49
+ }
50
+ }
51
+ logo = cleanSvg(markup);
52
+ if (!logo) warn(`brand.logo 不是 SVG:${brand.logo},不显示图标。`);
53
+ }
54
+ return {
55
+ logo,
56
+ favicon: publicUrl(brand.favicon || "/favicon.svg", basePath),
57
+ appleTouchIcon: brand.appleTouchIcon === false ? "" : publicUrl(brand.appleTouchIcon || "/apple-touch-icon.png", basePath),
58
+ };
59
+ }
@@ -10,6 +10,9 @@ 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 { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
14
+ import { resolveBrand } from "./brand.mjs";
15
+ import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
13
16
  import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
14
17
  import { coverImageSet, loadSharp } from "./cover-images.mjs";
15
18
 
@@ -39,6 +42,9 @@ const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "ht
39
42
  const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
40
43
  ? `${siteUrl}${basePath}`
41
44
  : siteUrl;
45
+ // Full URLs to this address are internal links: no new tab, no card.
46
+ const siteAddress = configuredSiteAddress(siteConfig, process.env);
47
+ const sameSite = sameSiteMatcher(siteAddress, basePath);
42
48
  const siteName = process.env.SITE_NAME ?? siteConfig.name;
43
49
  const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
44
50
 
@@ -527,6 +533,40 @@ const thoughts = readMarkdownTree(path.join(contentRoot, "thoughts"), "thought")
527
533
  (a, b) => Date.parse(b.date) - Date.parse(a.date),
528
534
  );
529
535
 
536
+ // Intrinsic image sizes for width/height on <img>: local files are read now,
537
+ // remote ones come from image-sizes.json (written by scripts/image-sizes.mjs).
538
+ function localImageFile(src) {
539
+ const clean = src.split(/[?#]/u, 1)[0];
540
+ const copied = assetFiles.get(clean)?.destination;
541
+ if (copied) return copied;
542
+ if (!clean.startsWith("/") || clean.startsWith("//")) return null;
543
+ let pathname;
544
+ try {
545
+ pathname = decodeURIComponent(basePath && clean.startsWith(`${basePath}/`) ? clean.slice(basePath.length) : clean);
546
+ } catch {
547
+ return null;
548
+ }
549
+ const publicDir = path.join(root, "public");
550
+ const file = path.resolve(publicDir, `.${pathname}`);
551
+ return file.startsWith(publicDir + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile() ? file : null;
552
+ }
553
+ const remoteImageSizes = readImageCache(root).images;
554
+ const imageSizes = {};
555
+ for (const entry of [...posts, ...columns, ...thoughts]) {
556
+ for (const src of imageSources(entry.content)) {
557
+ if (imageSizes[src]) continue;
558
+ if (/^https?:\/\//iu.test(src)) {
559
+ const size = remoteImageSizes[src];
560
+ if (size?.width > 0 && size?.height > 0) imageSizes[src] = { width: size.width, height: size.height };
561
+ continue;
562
+ }
563
+ const file = localImageFile(src);
564
+ const size = file ? sizeFromBytes(fs.readFileSync(file)) : null;
565
+ if (size) imageSizes[src] = size;
566
+ else if (file) console.warn(`警告:读不到图片尺寸,这张图不带宽高:${src}`);
567
+ }
568
+ }
569
+
530
570
  assertUnique(posts, (item) => item.path, "博文路径");
531
571
  assertUnique(columns, (item) => `${item.column}/${item.path}`, "专栏路径");
532
572
  assertUnique(
@@ -616,6 +656,7 @@ export const columns = ${JSON.stringify(columns, null, 2)} as ContentEntry[];
616
656
  export const thoughts = ${JSON.stringify(thoughts, null, 2)} as ContentEntry[];
617
657
  export const a2WordCounts = ${JSON.stringify(a2WordCounts, null, 2)} as Record<string, number>;
618
658
  export const linkAssets = ${JSON.stringify(linkAssetsForRender(root, basePath), null, 2)} as LinkAssets;
659
+ export const imageSizes: Record<string, { width: number; height: number }> = ${JSON.stringify(imageSizes)};
619
660
  `;
620
661
 
621
662
  fs.mkdirSync(path.dirname(output), { recursive: true });
@@ -630,6 +671,10 @@ export const githubUrl = ${JSON.stringify(`https://github.com/${siteConfig.autho
630
671
  export const searchIndexUrl = ${JSON.stringify(`${basePath}/search-index.json?v=${contentHash}`)};
631
672
  // Every body as plain text, fetched in the background after load; the name changes with the text.
632
673
  export const searchFull = ${JSON.stringify({ url: `${basePath}/${fullIndex.fileName}`, hash: fullIndex.hash })};
674
+ // Header logo (inlined SVG) and icon URLs, from brand in site.config.yaml.
675
+ export const brand = ${JSON.stringify(resolveBrand(root, siteConfig, basePath, (line) => console.warn(line)))};
676
+ // Full URLs to this address render as internal links.
677
+ export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath })};
633
678
  `,
634
679
  );
635
680
  fs.mkdirSync(path.join(root, "public"), { recursive: true });
@@ -701,7 +746,7 @@ const sitemapEntries = [
701
746
  // Card URLs in published entries: the only pages the link-card Worker may read.
702
747
  fs.writeFileSync(
703
748
  path.join(publicRoot, "link-allowlist.json"),
704
- `${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)))].sort() }, null, 2)}\n`,
749
+ `${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)).filter((url) => !sameSite(url)))].sort() }, null, 2)}\n`,
705
750
  );
706
751
  fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
707
752
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Intrinsic sizes of the images in post bodies, so every <img> can carry
4
+ * width and height and the page does not jump as lazy images load.
5
+ *
6
+ * - Local images are read at build time with image-size (see build-content).
7
+ * - Remote images are probed here: only the first bytes are downloaded, never
8
+ * the whole file. Results go into `image-sizes.json` (commit it), so later
9
+ * builds do not fetch them again. A failed probe is logged and retried next
10
+ * time; the image simply renders without a size. The build never fails.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import matter from "gray-matter";
16
+ import { imageSize } from "image-size";
17
+ import { isPublicUrl } from "./link-meta-core.mjs";
18
+ import { assertPublicDns, pool } from "./link-assets.mjs";
19
+ import { mapOutsideCode } from "../ui/garden/markdown-links.mjs";
20
+
21
+ export const CACHE_FILE = "image-sizes.json";
22
+ const HEAD_BYTES = [64 * 1024, 512 * 1024];
23
+ const TIMEOUT_MS = 8000;
24
+
25
+ // A destination may hold balanced parentheses, as in `image (9).png` written as `image%20(9).png`.
26
+ const MD_IMAGE = /!\[[^\]]*\]\(\s*(?:<([^>\n]+)>|((?:[^()\s]|\([^()\s]*\))+))(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/gu;
27
+ const HTML_IMAGE = /<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/giu;
28
+
29
+ /** Image sources written in a Markdown body, outside code, in order, without repeats. */
30
+ export function imageSources(markdown) {
31
+ const found = [];
32
+ mapOutsideCode(markdown, (text) => {
33
+ for (const match of text.matchAll(MD_IMAGE)) found.push(match[1] ?? match[2]);
34
+ for (const match of text.matchAll(HTML_IMAGE)) found.push(match[1]);
35
+ return text;
36
+ });
37
+ return [...new Set(found)];
38
+ }
39
+
40
+ const svgLength = (value) => {
41
+ const match = /^\s*([\d.]+)\s*(px)?\s*$/iu.exec(value ?? "");
42
+ return match ? Number(match[1]) : 0;
43
+ };
44
+
45
+ /**
46
+ * An SVG's size from its opening tag, even when the tag is cut off: drawing
47
+ * tools can put a huge attribute (draw.io's `content`) inside it.
48
+ */
49
+ export function svgSizeFromHead(text) {
50
+ const tag = /<svg\b[^>]*/iu.exec(text)?.[0];
51
+ if (!tag) return null;
52
+ const attr = (name) => new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, "iu").exec(tag)?.[1];
53
+ const width = svgLength(attr("width"));
54
+ const height = svgLength(attr("height"));
55
+ if (width > 0 && height > 0) return { width: Math.round(width), height: Math.round(height) };
56
+ const box = (attr("viewBox") ?? "").trim().split(/[\s,]+/u).map(Number);
57
+ if (box.length === 4 && box[2] > 0 && box[3] > 0) {
58
+ if (width > 0) return { width: Math.round(width), height: Math.round((width * box[3]) / box[2]) };
59
+ return { width: Math.round(box[2]), height: Math.round(box[3]) };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** `{ width, height }` from the first bytes of an image, or null. */
65
+ export function sizeFromBytes(bytes) {
66
+ const head = new TextDecoder().decode(bytes.subarray(0, 4096));
67
+ if (/^\s*(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE[^>]*>\s*)?<svg\b/iu.test(head)) return svgSizeFromHead(head);
68
+ try {
69
+ const result = imageSize(bytes);
70
+ // EXIF orientation 5–8 turns the picture a quarter: the browser swaps the sides.
71
+ const turned = result.orientation >= 5 && result.orientation <= 8;
72
+ const width = turned ? result.height : result.width;
73
+ const height = turned ? result.width : result.height;
74
+ return width > 0 && height > 0 ? { width, height } : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export function readImageCache(siteRoot) {
81
+ try {
82
+ const data = JSON.parse(fs.readFileSync(path.join(siteRoot, CACHE_FILE), "utf8"));
83
+ return { images: data?.images && typeof data.images === "object" ? data.images : {} };
84
+ } catch {
85
+ return { images: {} };
86
+ }
87
+ }
88
+
89
+ /** Read at most `limit` bytes of the response body, then stop the download. */
90
+ async function readHead(response, limit) {
91
+ if (!response.body) return new Uint8Array(await response.arrayBuffer()).slice(0, limit);
92
+ const reader = response.body.getReader();
93
+ const chunks = [];
94
+ let total = 0;
95
+ try {
96
+ while (total < limit) {
97
+ const { done, value } = await reader.read();
98
+ if (done) break;
99
+ chunks.push(value);
100
+ total += value.length;
101
+ }
102
+ } finally {
103
+ await reader.cancel().catch(() => {});
104
+ }
105
+ const bytes = new Uint8Array(Math.min(total, limit));
106
+ let offset = 0;
107
+ for (const chunk of chunks) {
108
+ const part = chunk.subarray(0, Math.min(chunk.length, bytes.length - offset));
109
+ bytes.set(part, offset);
110
+ offset += part.length;
111
+ if (offset >= bytes.length) break;
112
+ }
113
+ return bytes;
114
+ }
115
+
116
+ /** Fetch the head of an image, following up to 3 redirects to public hosts only. */
117
+ async function fetchHead(url, limit, fetcher, checkHost) {
118
+ let current = url;
119
+ for (let hop = 0; hop < 4; hop += 1) {
120
+ if (!isPublicUrl(current)) throw new Error("not a public URL");
121
+ await checkHost(new URL(current).hostname);
122
+ const response = await fetcher(current, {
123
+ redirect: "manual",
124
+ headers: { range: `bytes=0-${limit - 1}`, accept: "image/*", "user-agent": "Mozilla/5.0 (compatible; GardenImageSize/1.0)" },
125
+ signal: AbortSignal.timeout(TIMEOUT_MS),
126
+ });
127
+ if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
128
+ await response.body?.cancel().catch(() => {});
129
+ current = new URL(response.headers.get("location"), current).toString();
130
+ continue;
131
+ }
132
+ if (!response.ok) {
133
+ await response.body?.cancel().catch(() => {});
134
+ throw new Error(`HTTP ${response.status}`);
135
+ }
136
+ return readHead(response, limit);
137
+ }
138
+ throw new Error("too many redirects");
139
+ }
140
+
141
+ export async function probeRemoteSize(url, { fetch: fetcher = globalThis.fetch, checkHost = assertPublicDns } = {}) {
142
+ let lastError = "unreadable";
143
+ for (const limit of HEAD_BYTES) {
144
+ try {
145
+ const size = sizeFromBytes(await fetchHead(url, limit, fetcher, checkHost));
146
+ if (size) return size;
147
+ } catch (error) {
148
+ lastError = error?.message ?? String(error);
149
+ break;
150
+ }
151
+ }
152
+ throw new Error(lastError);
153
+ }
154
+
155
+ function markdownFiles(directory) {
156
+ if (!fs.existsSync(directory)) return [];
157
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
158
+ const full = path.join(directory, entry.name);
159
+ if (entry.isDirectory()) return markdownFiles(full);
160
+ return /\.mdx?$/iu.test(entry.name) ? [full] : [];
161
+ });
162
+ }
163
+
164
+ /** Remote image URLs across every Markdown file under content/. */
165
+ export function collectRemoteImages(siteRoot) {
166
+ const urls = new Set();
167
+ for (const file of markdownFiles(path.join(siteRoot, "content"))) {
168
+ const { content } = matter(fs.readFileSync(file, "utf8"));
169
+ for (const src of imageSources(content)) if (/^https?:\/\//iu.test(src)) urls.add(src);
170
+ }
171
+ return [...urls].sort();
172
+ }
173
+
174
+ export async function refreshImageSizes({ siteRoot, fetch: fetcher, checkHost, log = () => {}, concurrency = 6 }) {
175
+ const cache = readImageCache(siteRoot);
176
+ const urls = collectRemoteImages(siteRoot);
177
+ const images = {};
178
+ const missing = [];
179
+ for (const url of urls) {
180
+ if (cache.images[url]?.width > 0 && cache.images[url]?.height > 0) images[url] = cache.images[url];
181
+ else missing.push(url);
182
+ }
183
+ let failed = 0;
184
+ await pool(missing, concurrency, async (url) => {
185
+ try {
186
+ images[url] = await probeRemoteSize(url, { fetch: fetcher, checkHost });
187
+ } catch (error) {
188
+ failed += 1;
189
+ log(`警告:读不到图片尺寸,这张图不带宽高:${url}(${error.message})`);
190
+ }
191
+ });
192
+ const sorted = Object.fromEntries(Object.keys(images).sort().map((url) => [url, images[url]]));
193
+ const next = `${JSON.stringify({ version: 1, images: sorted }, null, 2)}\n`;
194
+ const file = path.join(siteRoot, CACHE_FILE);
195
+ const previous = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
196
+ if (next !== previous && (urls.length || previous)) fs.writeFileSync(file, next);
197
+ log(`图片尺寸:${urls.length} 张远程图片,新读取 ${missing.length - failed} 张,失败 ${failed} 张。`);
198
+ return { images: sorted, probed: missing.length - failed, failed };
199
+ }
200
+
201
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
202
+ const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
203
+ await refreshImageSizes({ siteRoot, log: (line) => process.stdout.write(`${line}\n`) }).catch((error) => {
204
+ process.stdout.write(`警告:图片尺寸没有更新(${error.message}),继续构建。\n`);
205
+ });
206
+ }
@@ -17,7 +17,9 @@ import path from "node:path";
17
17
  import { pathToFileURL } from "node:url";
18
18
  import matter from "gray-matter";
19
19
  import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, linkHost } from "./link-meta-core.mjs";
20
+ import { parse as parseYaml } from "yaml";
20
21
  import { markdownLinks } from "../ui/garden/markdown-links.mjs";
22
+ import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
21
23
 
22
24
  export const CACHE_FILE = "link-assets.json";
23
25
  export const ASSET_DIR = path.join("public", "link-assets");
@@ -49,15 +51,26 @@ function markdownFiles(directory) {
49
51
  });
50
52
  }
51
53
 
54
+ function readSiteConfig(siteRoot) {
55
+ try {
56
+ return parseYaml(fs.readFileSync(path.join(siteRoot, "site.config.yaml"), "utf8")) ?? {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
52
62
  /** External links across every post: the sites (for favicons) and the URLs. */
53
- export function collectSiteLinks(siteRoot) {
63
+ export function collectSiteLinks(siteRoot, env = process.env) {
64
+ const config = readSiteConfig(siteRoot);
65
+ // Links back to this site are internal: no favicon, no card.
66
+ const sameSite = sameSiteMatcher(configuredSiteAddress(config, env), env.NEXT_PUBLIC_BASE_PATH ?? "");
54
67
  const hosts = new Map();
55
68
  const cards = new Set();
56
69
  const urls = new Set();
57
70
  for (const file of markdownFiles(path.join(siteRoot, "content"))) {
58
71
  const { content } = matter(fs.readFileSync(file, "utf8"));
59
72
  for (const { url, card } of markdownLinks(content)) {
60
- if (!isPublicUrl(url)) continue;
73
+ if (!isPublicUrl(url) || sameSite(url)) continue;
61
74
  urls.add(url);
62
75
  if (card) cards.add(url);
63
76
  const host = linkHost(url);
@@ -81,14 +94,14 @@ export async function assertPublicDns(hostname) {
81
94
  }
82
95
  }
83
96
 
84
- function guardedFetch(fetcher) {
97
+ export function guardedFetch(fetcher) {
85
98
  return async (url, init) => {
86
99
  await assertPublicDns(new URL(url).hostname);
87
100
  return fetcher(url, init);
88
101
  };
89
102
  }
90
103
 
91
- async function pool(items, size, worker) {
104
+ export async function pool(items, size, worker) {
92
105
  const queue = [...items];
93
106
  const runners = Array.from({ length: Math.min(size, queue.length) }, async () => {
94
107
  while (queue.length) await worker(queue.shift());
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pages for old addresses. `redirects` in site.config.yaml maps an old path to
4
+ * where the page lives now:
5
+ *
6
+ * redirects:
7
+ * /2024/01/20/go/go-gpm/: /blog/go/go-gpm/
8
+ *
9
+ * A static export (GitHub Pages and the like) has no server to answer with a
10
+ * 301, so each old path gets a small page that sends the reader on: a meta
11
+ * refresh, a script that keeps the #anchor, and rel="canonical" for search
12
+ * engines. Runs after the build, into dist/client.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { parse as parseYaml } from "yaml";
18
+ import { configuredSiteAddress } from "../ui/garden/same-site.mjs";
19
+
20
+ const escapeHtml = (value) => String(value).replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
21
+
22
+ /** Valid `{ from, to }` pairs from the config; anything malformed is reported and skipped. */
23
+ export function readRedirects(config = {}, warn = () => {}) {
24
+ const map = config?.redirects;
25
+ if (!map) return [];
26
+ if (typeof map !== "object" || Array.isArray(map)) {
27
+ warn("redirects 应写成「旧路径: 新地址」的映射,已忽略。");
28
+ return [];
29
+ }
30
+ const pairs = [];
31
+ for (const [rawFrom, rawTo] of Object.entries(map)) {
32
+ let from = String(rawFrom).trim();
33
+ const to = String(rawTo ?? "").trim();
34
+ try {
35
+ from = decodeURI(from);
36
+ } catch {
37
+ // keep as written
38
+ }
39
+ if (!from.startsWith("/") || from.startsWith("//") || from === "/" || /(^|\/)\.\.?(\/|$)/u.test(from) || /[?#]/u.test(from)) {
40
+ warn(`redirects:旧路径 ${rawFrom} 必须是站内路径(以 / 开头,不带 ?、# 和 ..),已跳过。`);
41
+ continue;
42
+ }
43
+ if (!(to.startsWith("/") && !to.startsWith("//")) && !/^https?:\/\//iu.test(to)) {
44
+ warn(`redirects:${rawFrom} 的新地址 ${rawTo} 应以 / 开头或是完整网址,已跳过。`);
45
+ continue;
46
+ }
47
+ pairs.push({ from, to });
48
+ }
49
+ return pairs;
50
+ }
51
+
52
+ /** The file an old path is served from: `/a/b/` and `/a/b` → a/b/index.html, `/a/b.html` → a/b.html. */
53
+ export function redirectFile(from) {
54
+ const clean = from.replace(/^\/+/u, "");
55
+ return /\.html?$/iu.test(clean) ? clean : path.posix.join(clean.replace(/\/+$/u, ""), "index.html");
56
+ }
57
+
58
+ export function redirectPage({ href, canonical }) {
59
+ return `<!doctype html>
60
+ <html lang="zh-CN">
61
+ <head>
62
+ <meta charset="utf-8">
63
+ <title>页面已移动</title>
64
+ <link rel="canonical" href="${escapeHtml(canonical)}">
65
+ <meta http-equiv="refresh" content="0; url=${escapeHtml(href)}">
66
+ <script>location.replace(${JSON.stringify(href).replace(/</gu, "\\u003c")} + location.hash)</script>
67
+ </head>
68
+ <body>
69
+ <p>页面已移到 <a href="${escapeHtml(href)}">${escapeHtml(canonical)}</a>。</p>
70
+ </body>
71
+ </html>
72
+ `;
73
+ }
74
+
75
+ /**
76
+ * Write one page per redirect into `outDir`. A redirect is skipped, with a
77
+ * warning, when a real page already lives at the old path or when a site path
78
+ * it points to was not built. Never throws for a bad entry.
79
+ */
80
+ export function writeRedirects({ outDir, config = {}, env = {}, basePath = "", log = () => {} }) {
81
+ const base = String(basePath ?? "").replace(/\/+$/u, "");
82
+ let origin = "";
83
+ try {
84
+ const address = configuredSiteAddress(config, env);
85
+ origin = address ? new URL(address).origin : "";
86
+ } catch {
87
+ origin = "";
88
+ }
89
+ const written = [];
90
+ for (const { from, to } of readRedirects(config, log)) {
91
+ const file = path.join(outDir, redirectFile(from));
92
+ if (!path.resolve(file).startsWith(path.resolve(outDir) + path.sep)) continue;
93
+ if (fs.existsSync(file)) {
94
+ log(`redirects:${from} 已经有页面,没有覆盖。`);
95
+ continue;
96
+ }
97
+ const external = /^https?:\/\//iu.test(to);
98
+ if (!external) {
99
+ const target = to.split(/[?#]/u, 1)[0];
100
+ const built = [path.join(outDir, redirectFile(target)), path.join(outDir, target.replace(/^\/+/u, ""))]
101
+ .some((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
102
+ if (!built) {
103
+ log(`redirects:${from} 指向的 ${to} 不存在,已跳过。`);
104
+ continue;
105
+ }
106
+ }
107
+ const href = external ? to : `${base}${to}`;
108
+ const canonical = external || !origin ? href : `${origin}${href}`;
109
+ fs.mkdirSync(path.dirname(file), { recursive: true });
110
+ fs.writeFileSync(file, redirectPage({ href, canonical }));
111
+ written.push({ from, to: href, file });
112
+ }
113
+ return written;
114
+ }
115
+
116
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
117
+ const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
118
+ const outDir = path.join(siteRoot, "dist", "client");
119
+ try {
120
+ const config = parseYaml(fs.readFileSync(path.join(siteRoot, "site.config.yaml"), "utf8")) ?? {};
121
+ if (config.redirects && fs.existsSync(outDir)) {
122
+ const written = writeRedirects({ outDir, config, env: process.env, basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? "", log: (line) => process.stdout.write(`警告:${line}\n`) });
123
+ process.stdout.write(`旧地址跳转页:${written.length} 个。\n`);
124
+ }
125
+ } catch (error) {
126
+ process.stdout.write(`警告:旧地址跳转页没有生成(${error.message}),继续构建。\n`);
127
+ }
128
+ }
@@ -2,6 +2,13 @@ name: Garden
2
2
  tagline: 写作 · 记录 · 保持好奇
3
3
  description: 文章、随想和专栏。
4
4
  locale: zh_CN
5
+ # 站点的公开地址。文章里指向这个地址的完整网址会按站内链接显示。
6
+ # url: https://blog.example.com
7
+ # 站点图标,不写时使用 Garden 自带的图标。
8
+ # brand:
9
+ # logo: brand/logo.svg
10
+ # favicon: /favicon.svg
11
+ # appleTouchIcon: /apple-touch-icon.png
5
12
 
6
13
  author:
7
14
  name: Your Name
@@ -131,6 +131,36 @@ export function figureBreakout({
131
131
  return { left: side, right: side };
132
132
  }
133
133
 
134
+ /**
135
+ * The same decision made at build time, when the image's pixel size is known:
136
+ * a figure in a full-width article column (`column` wide, the image card
137
+ * `inset` narrower) with room to spare on both sides. Returns how far the
138
+ * figure steps out on each side (`side`, 0 when it stays in the column) and
139
+ * the inline width the image gets, so its box is the right size before the
140
+ * file loads. The browser repeats the calculation after hydration and keeps
141
+ * it as the fallback for images without a known size.
142
+ */
143
+ export function figureLayout({ naturalWidth = 0, attrWidth = 0, column = 740, inset = 26, density = 2, maxWidth = 1040 }) {
144
+ if (!(naturalWidth > 0)) return { side: 0, width: attrWidth > 0 ? attrWidth : 0 };
145
+ // Before any widening the image shows at its own pixels (or its width attribute), capped at the card.
146
+ const base = attrWidth > 0 ? Math.min(attrWidth, naturalWidth) : naturalWidth;
147
+ const viewport = 100000;
148
+ const articleLeft = (viewport - column) / 2;
149
+ const { left } = figureBreakout({
150
+ viewport,
151
+ articleLeft,
152
+ articleRight: articleLeft + column,
153
+ figureClient: column,
154
+ imageClient: Math.min(base, column - inset),
155
+ naturalWidth,
156
+ attrWidth,
157
+ density,
158
+ maxWidth,
159
+ });
160
+ const widened = figureImageWidth({ naturalWidth, attrWidth, widened: left > 0, density });
161
+ return { side: left, width: widened || base };
162
+ }
163
+
134
164
  /**
135
165
  * Slide the TOC right of a wide element measured against the TOC's original
136
166
  * position. Hide it when the shifted TOC would not fit on screen.
@@ -0,0 +1,43 @@
1
+ import { figureLayout } from "./enhance.mjs";
2
+
3
+ const decode = (value) => {
4
+ try {
5
+ return decodeURI(value);
6
+ } catch {
7
+ return value;
8
+ }
9
+ };
10
+
11
+ /** The build-time size for an image src, whether or not the renderer percent-encoded it. */
12
+ export function lookupImageSize(sizes, src) {
13
+ if (!src || !sizes) return null;
14
+ const size = sizes[src] ?? sizes[decode(src)] ?? sizes[encodeURI(decode(src))];
15
+ return size?.width > 0 && size?.height > 0 ? size : null;
16
+ }
17
+
18
+ /**
19
+ * Attributes that give an article image its box before the file loads:
20
+ * width and height (the browser takes the aspect ratio from them; CSS keeps
21
+ * height:auto so it stays responsive) and an inline width matching what the
22
+ * image would show once loaded. An author-written width wins and only gains
23
+ * the matching height. Without a known size nothing is added.
24
+ */
25
+ export function imageDimensions({ width, height } = {}, size) {
26
+ if (!size) return {};
27
+ const authoredWidth = Number(width) > 0 ? Number(width) : 0;
28
+ const authoredHeight = Number(height) > 0 ? Number(height) : 0;
29
+ // Author-written sizes are never replaced; only the missing side is filled in from the ratio.
30
+ if (authoredWidth || authoredHeight) {
31
+ const w = authoredWidth || Math.round((authoredHeight * size.width) / size.height);
32
+ const h = authoredHeight || Math.round((authoredWidth * size.height) / size.width);
33
+ return { width: w, height: h, "data-nw": size.width, "data-aw": w };
34
+ }
35
+ const { width: shown } = figureLayout({ naturalWidth: size.width });
36
+ return {
37
+ width: size.width,
38
+ height: size.height,
39
+ "data-nw": size.width,
40
+ "data-bw": shown,
41
+ style: { width: `${shown}px` },
42
+ };
43
+ }