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.
- package/app/blog/[...path]/page.tsx +1 -1
- package/app/columns/[column]/[...path]/page.tsx +1 -1
- package/app/components/ArticleMeasure.tsx +15 -6
- package/app/components/MarkdownArticle.tsx +16 -7
- package/app/components/MarkdownImage.tsx +4 -3
- package/app/components/SiteHeader.tsx +7 -3
- package/app/garden-modules.d.ts +8 -0
- package/app/globals.css +21 -18
- package/app/layout.tsx +3 -1
- package/app/lib/remark-a2.mjs +19 -11
- package/app/lib/remark-link-card.mjs +11 -9
- package/app/styles/a2.css +27 -21
- package/app/styles/shell.css +13 -6
- package/app/styles/tokens.css +1 -1
- package/bin/garden.mjs +9 -1
- package/package.json +3 -1
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.svg +1 -4
- package/scripts/brand.mjs +59 -0
- package/scripts/build-content.mjs +46 -1
- package/scripts/image-sizes.mjs +206 -0
- package/scripts/link-assets.mjs +17 -4
- package/scripts/redirects.mjs +128 -0
- package/templates/site/site.config.yaml +7 -0
- package/ui/garden/enhance.mjs +30 -0
- package/ui/garden/image-dims.mjs +43 -0
- package/ui/garden/same-site.mjs +63 -0
|
@@ -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
|
+
}
|
package/scripts/link-assets.mjs
CHANGED
|
@@ -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, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
|
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
|
package/ui/garden/enhance.mjs
CHANGED
|
@@ -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
|
+
}
|