hd-garden 0.5.2 → 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 +8 -3
- package/app/components/MarkdownImage.tsx +4 -3
- package/app/components/SiteHeader.tsx +7 -3
- package/app/garden-modules.d.ts +5 -0
- package/app/globals.css +0 -14
- package/app/layout.tsx +3 -1
- package/app/lib/remark-a2.mjs +15 -7
- package/app/styles/a2.css +15 -2
- package/app/styles/shell.css +13 -6
- 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 +39 -0
- package/scripts/image-sizes.mjs +206 -0
- package/scripts/link-assets.mjs +2 -2
- package/scripts/redirects.mjs +128 -0
- package/templates/site/site.config.yaml +5 -0
- package/ui/garden/enhance.mjs +30 -0
- package/ui/garden/image-dims.mjs +43 -0
|
@@ -111,7 +111,7 @@ export default async function PostPage({
|
|
|
111
111
|
<ArticleTags tags={post.tags} />
|
|
112
112
|
{post.cover && (
|
|
113
113
|
<figure className="article-hero article-cover cover-card cover-img">
|
|
114
|
-
<CoverImage className="u-photo" src={post.cover} image={post.coverImage} alt={`${post.title} 封面`} sizes="(max-width:
|
|
114
|
+
<CoverImage className="u-photo" src={post.cover} image={post.coverImage} alt={`${post.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
|
|
115
115
|
</figure>
|
|
116
116
|
)}
|
|
117
117
|
</header>
|
|
@@ -131,7 +131,7 @@ export default async function ColumnEntryPage({
|
|
|
131
131
|
<ArticleTags tags={entry.tags} />
|
|
132
132
|
{entry.cover && (
|
|
133
133
|
<figure className="article-hero article-cover cover-img">
|
|
134
|
-
<CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={`${entry.title} 封面`} sizes="(max-width:
|
|
134
|
+
<CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={`${entry.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
|
|
135
135
|
</figure>
|
|
136
136
|
)}
|
|
137
137
|
</header>
|
|
@@ -101,23 +101,32 @@ export function ArticleMeasure() {
|
|
|
101
101
|
article.querySelectorAll<HTMLElement>(".fig").forEach((figure) => {
|
|
102
102
|
const image = figure.querySelector("img");
|
|
103
103
|
resetWide(figure);
|
|
104
|
+
// From here on the browser decides; the build-time breakout CSS steps aside.
|
|
105
|
+
figure.classList.remove("fig-wide");
|
|
104
106
|
if (!image) return;
|
|
105
|
-
// Back to
|
|
106
|
-
image.
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// Back to the unwidened size: the build-time width when the size is known
|
|
108
|
+
// (so an image that has not loaded keeps its box), else CSS sizing.
|
|
109
|
+
const baseWidth = Number(image.dataset.bw) || 0;
|
|
110
|
+
if (baseWidth) image.style.width = `${baseWidth}px`;
|
|
111
|
+
else image.style.removeProperty("width");
|
|
112
|
+
// Sizes read at build time let this run before the file arrives; otherwise wait for load.
|
|
113
|
+
const known = Number(image.dataset.nw) || 0;
|
|
114
|
+
const pixels = image.naturalWidth || known;
|
|
115
|
+
if (!pixels) return;
|
|
116
|
+
// width="" written by the build is the pixel size, not an author's choice.
|
|
117
|
+
const attrWidth = known ? Number(image.dataset.aw) || 0 : Number(image.getAttribute("width")) || 0;
|
|
109
118
|
const breakout = figureBreakout({
|
|
110
119
|
viewport,
|
|
111
120
|
articleLeft: articleBox.left,
|
|
112
121
|
articleRight: articleBox.right,
|
|
113
122
|
figureClient: figure.clientWidth,
|
|
114
123
|
imageClient: image.clientWidth,
|
|
115
|
-
naturalWidth:
|
|
124
|
+
naturalWidth: pixels,
|
|
116
125
|
attrWidth,
|
|
117
126
|
});
|
|
118
127
|
widen(figure, breakout);
|
|
119
128
|
// Only a widened image, or one with a width attribute, gets an inline width.
|
|
120
|
-
const width = figureImageWidth({ naturalWidth:
|
|
129
|
+
const width = figureImageWidth({ naturalWidth: pixels, attrWidth, widened: breakout.left > 0 });
|
|
121
130
|
if (width) image.style.width = `${width}px`;
|
|
122
131
|
});
|
|
123
132
|
placeToc();
|
|
@@ -20,7 +20,8 @@ import { MarkdownImage } from "./MarkdownImage";
|
|
|
20
20
|
import { CodeCopy } from "./garden/chrome";
|
|
21
21
|
import { CodeDiff } from "./garden/chrome";
|
|
22
22
|
import { GardenBlock } from "./garden/GardenBlock";
|
|
23
|
-
import { linkAssets } from "@garden/generated-content";
|
|
23
|
+
import { imageSizes, linkAssets } from "@garden/generated-content";
|
|
24
|
+
import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs";
|
|
24
25
|
import { siteConfig, siteLinks } from "@garden/site-config";
|
|
25
26
|
import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
|
|
26
27
|
import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
@@ -225,7 +226,7 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
225
226
|
return (
|
|
226
227
|
<div className="markdown-body prose">
|
|
227
228
|
<ReactMarkdown
|
|
228
|
-
remarkPlugins={[...markdownPlugins, [remarkLinkCard, { sameSite }], remarkA2]}
|
|
229
|
+
remarkPlugins={[...markdownPlugins, [remarkLinkCard, { sameSite }], [remarkA2, { imageSize: (src: string) => lookupImageSize(imageSizes, src) }]]}
|
|
229
230
|
rehypePlugins={[
|
|
230
231
|
rehypeRaw,
|
|
231
232
|
rehypeKatex,
|
|
@@ -260,7 +261,11 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
260
261
|
</a>
|
|
261
262
|
);
|
|
262
263
|
},
|
|
263
|
-
img:
|
|
264
|
+
img: (props) => {
|
|
265
|
+
const size = lookupImageSize(imageSizes, typeof props.src === "string" ? props.src : undefined);
|
|
266
|
+
const dims = imageDimensions(props, size);
|
|
267
|
+
return <MarkdownImage {...props} {...dims} style={{ ...props.style, ...dims.style }} />;
|
|
268
|
+
},
|
|
264
269
|
table: ({ children, ...props }) => (
|
|
265
270
|
<div className="table-wrap" role="region" aria-label="表格,可横向滚动" tabIndex={0}>
|
|
266
271
|
<table {...props}>{children}</table>
|
|
@@ -6,7 +6,7 @@ import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
|
|
|
6
6
|
type MarkdownImageProps = ComponentPropsWithoutRef<"img"> & { node?: unknown };
|
|
7
7
|
|
|
8
8
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
9
|
-
export function MarkdownImage({ alt = "", src, node, ...props }: MarkdownImageProps) {
|
|
9
|
+
export function MarkdownImage({ alt = "", src, node, style, ...props }: MarkdownImageProps) {
|
|
10
10
|
const [open, setOpen] = useState(false);
|
|
11
11
|
const description = alt || "查看原图";
|
|
12
12
|
|
|
@@ -37,7 +37,7 @@ export function MarkdownImage({ alt = "", src, node, ...props }: MarkdownImagePr
|
|
|
37
37
|
>
|
|
38
38
|
{/* Native images preserve Typora-authored relative paths. */}
|
|
39
39
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
40
|
-
<img loading="lazy" alt={alt} src={src} {...props} />
|
|
40
|
+
<img loading="lazy" alt={alt} src={src} {...props} style={style} />
|
|
41
41
|
</button>
|
|
42
42
|
|
|
43
43
|
{open ? (
|
|
@@ -49,8 +49,9 @@ export function MarkdownImage({ alt = "", src, node, ...props }: MarkdownImagePr
|
|
|
49
49
|
onClick={() => setOpen(false)}
|
|
50
50
|
>
|
|
51
51
|
<div className="markdown-image-lightbox-content" onClick={(event) => event.stopPropagation()}>
|
|
52
|
+
{/* The preview sizes itself; the article's inline width stays on the article image. */}
|
|
52
53
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
53
|
-
<img src={src} alt={alt} {
|
|
54
|
+
<img src={src} alt={alt} width={props.width} height={props.height} />
|
|
54
55
|
<button
|
|
55
56
|
type="button"
|
|
56
57
|
className="markdown-image-lightbox-close"
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "react";
|
|
13
13
|
import type Fuse from "fuse.js";
|
|
14
14
|
import { MagnifyingGlass, X } from "@phosphor-icons/react";
|
|
15
|
-
import { searchFull, searchIndexUrl } from "@garden/site-config";
|
|
15
|
+
import { brand, searchFull, searchIndexUrl } from "@garden/site-config";
|
|
16
16
|
import { createSearch } from "../../ui/garden/search-match.mjs";
|
|
17
17
|
import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
|
|
18
18
|
import { siteConfig } from "../site.config";
|
|
@@ -358,7 +358,11 @@ export function SiteHeader() {
|
|
|
358
358
|
<>
|
|
359
359
|
<header className="sh-header">
|
|
360
360
|
<div className="sh-bar">
|
|
361
|
-
<Link href="/" className="sh-word">
|
|
361
|
+
<Link href="/" className="sh-word">
|
|
362
|
+
{/* Build-time SVG from brand.logo, cleaned of scripts and handlers. */}
|
|
363
|
+
{brand.logo ? <span className="sh-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: brand.logo }} /> : null}
|
|
364
|
+
{siteConfig.name}
|
|
365
|
+
</Link>
|
|
362
366
|
<nav className="sh-nav" aria-label="站点导航">
|
|
363
367
|
{nav.map((item) => (
|
|
364
368
|
<Link key={item.href} href={item.href} aria-current={current(item.href) ? "page" : undefined}>
|
|
@@ -372,7 +376,7 @@ export function SiteHeader() {
|
|
|
372
376
|
<circle cx="9" cy="9" r="5.8" />
|
|
373
377
|
<path d="M13.3 13.3 17 17" />
|
|
374
378
|
</svg>
|
|
375
|
-
<span className="lbl"
|
|
379
|
+
<span className="lbl">搜索</span>
|
|
376
380
|
<kbd><span className="kbd-mod kbd-mac">⌘</span><span className="kbd-mod kbd-other">Ctrl</span>{" "}K</kbd>
|
|
377
381
|
</button>
|
|
378
382
|
<button className="sh-icon sh-search-m" type="button" onClick={openSearch} onPointerDown={prefetchSearch} aria-label="搜索文章">
|
package/app/garden-modules.d.ts
CHANGED
|
@@ -59,6 +59,8 @@ declare module "@garden/generated-content" {
|
|
|
59
59
|
};
|
|
60
60
|
export type LinkAssets = { favicons: Record<string, string>; cards: Record<string, LinkCardMeta> };
|
|
61
61
|
export const linkAssets: LinkAssets;
|
|
62
|
+
/** Intrinsic sizes of body images by src, read at build time. */
|
|
63
|
+
export const imageSizes: Record<string, { width: number; height: number }>;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
declare module "@garden/site-config" {
|
|
@@ -67,6 +69,8 @@ declare module "@garden/site-config" {
|
|
|
67
69
|
export const searchFull: { url: string; hash: string };
|
|
68
70
|
/** The site address (site.config.yaml `url`, else SITE_URL) and base path, for spotting links back to the site. */
|
|
69
71
|
export const siteLinks: { address: string; basePath: string };
|
|
72
|
+
/** Header logo as inline SVG ("" when hidden) and icon URLs, from brand in site.config.yaml. */
|
|
73
|
+
export const brand: { logo: string; favicon: string; appleTouchIcon: string };
|
|
70
74
|
export const siteConfig: {
|
|
71
75
|
name: string;
|
|
72
76
|
tagline: string;
|
|
@@ -77,6 +81,7 @@ declare module "@garden/site-config" {
|
|
|
77
81
|
footer: string;
|
|
78
82
|
linkPreview?: { worker?: string };
|
|
79
83
|
url?: string;
|
|
84
|
+
brand?: { logo?: string | false; favicon?: string; appleTouchIcon?: string | false };
|
|
80
85
|
};
|
|
81
86
|
export const githubUrl: string;
|
|
82
87
|
}
|
package/app/globals.css
CHANGED
|
@@ -126,15 +126,6 @@ button {
|
|
|
126
126
|
width: max-content;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
.brand-mark {
|
|
130
|
-
width: 43px;
|
|
131
|
-
height: 50px;
|
|
132
|
-
display: grid;
|
|
133
|
-
place-items: center;
|
|
134
|
-
color: var(--faint);
|
|
135
|
-
border-right: 1px solid var(--line-strong);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
129
|
.brand strong {
|
|
139
130
|
display: block;
|
|
140
131
|
font-size: 20px;
|
|
@@ -4115,11 +4106,6 @@ html[data-theme="dark"] .article-references {
|
|
|
4115
4106
|
display: none;
|
|
4116
4107
|
}
|
|
4117
4108
|
|
|
4118
|
-
.brand-mark {
|
|
4119
|
-
width: 36px;
|
|
4120
|
-
height: 44px;
|
|
4121
|
-
}
|
|
4122
|
-
|
|
4123
4109
|
.brand strong {
|
|
4124
4110
|
font-size: 17px;
|
|
4125
4111
|
}
|
package/app/layout.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
webmentionEndpoint,
|
|
12
12
|
} from "./lib/site";
|
|
13
13
|
import { githubUrl, siteConfig } from "./site.config";
|
|
14
|
+
import { brand } from "@garden/site-config";
|
|
14
15
|
|
|
15
16
|
export async function generateMetadata(): Promise<Metadata> {
|
|
16
17
|
const siteUrl = await getSiteUrl();
|
|
@@ -29,7 +30,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
29
30
|
},
|
|
30
31
|
},
|
|
31
32
|
icons: {
|
|
32
|
-
icon:
|
|
33
|
+
icon: [{ url: brand.favicon, type: brand.favicon.endsWith(".svg") ? "image/svg+xml" : undefined }],
|
|
34
|
+
...(brand.appleTouchIcon ? { apple: [{ url: brand.appleTouchIcon, sizes: "180x180" }] } : {}),
|
|
33
35
|
},
|
|
34
36
|
robots: {
|
|
35
37
|
index: true,
|
package/app/lib/remark-a2.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { figureLayout } from "../../ui/garden/enhance.mjs";
|
|
1
2
|
import { toString } from "mdast-util-to-string";
|
|
2
3
|
|
|
3
4
|
const ALERT_TITLE = {
|
|
@@ -168,7 +169,7 @@ function figureCaption(image) {
|
|
|
168
169
|
return "";
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
function asFigure(node) {
|
|
172
|
+
function asFigure(node, imageSize) {
|
|
172
173
|
if (node?.type !== "paragraph" || node.data?.hName) return null;
|
|
173
174
|
const children = meaningfulChildren(node);
|
|
174
175
|
if (children.length !== 1 || children[0].type !== "image") return null;
|
|
@@ -186,23 +187,30 @@ function asFigure(node) {
|
|
|
186
187
|
children: [{ type: "text", value: caption }],
|
|
187
188
|
}]
|
|
188
189
|
: [card];
|
|
190
|
+
// A known size decides the breakout now, so the figure does not jump when the image loads.
|
|
191
|
+
const size = imageSize(image.url);
|
|
192
|
+
const side = size ? figureLayout({ naturalWidth: size.width }).side : 0;
|
|
193
|
+
const hProperties = side > 0
|
|
194
|
+
? { className: ["fig", "fig-wide"], style: `--wl:${side}px;--wr:${side}px` }
|
|
195
|
+
: { className: ["fig"] };
|
|
189
196
|
return {
|
|
190
197
|
type: "paragraph",
|
|
191
|
-
data: { hName: "figure", hProperties
|
|
198
|
+
data: { hName: "figure", hProperties },
|
|
192
199
|
children: figureChildren,
|
|
193
200
|
};
|
|
194
201
|
}
|
|
195
202
|
|
|
196
|
-
function walk(node) {
|
|
203
|
+
function walk(node, imageSize) {
|
|
197
204
|
if (!node || typeof node !== "object" || !Array.isArray(node.children)) return;
|
|
198
205
|
if (node.type === "blockquote") rewriteAlert(node);
|
|
199
|
-
node.children = node.children.map((child) => asFigure(child) ?? child);
|
|
200
|
-
for (const child of node.children) walk(child);
|
|
206
|
+
node.children = node.children.map((child) => asFigure(child, imageSize) ?? child);
|
|
207
|
+
for (const child of node.children) walk(child, imageSize);
|
|
201
208
|
}
|
|
202
209
|
|
|
203
210
|
/** Demo alert cards and captioned figures. Article rendering only. */
|
|
204
|
-
export function remarkA2() {
|
|
211
|
+
export function remarkA2(options = {}) {
|
|
212
|
+
const imageSize = typeof options?.imageSize === "function" ? options.imageSize : () => null;
|
|
205
213
|
return (tree) => {
|
|
206
|
-
walk(tree);
|
|
214
|
+
walk(tree, imageSize);
|
|
207
215
|
};
|
|
208
216
|
}
|
package/app/styles/a2.css
CHANGED
|
@@ -27,7 +27,6 @@ body {
|
|
|
27
27
|
color: var(--ink);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
.brand-mark,
|
|
31
30
|
.brand small {
|
|
32
31
|
display: none;
|
|
33
32
|
}
|
|
@@ -244,7 +243,8 @@ body {
|
|
|
244
243
|
.cover-card.cover-img,
|
|
245
244
|
.article-hero.article-cover {
|
|
246
245
|
width: 100%;
|
|
247
|
-
|
|
246
|
+
/* Column plus half a gutter each side: centred, and clear of the TOC rail. */
|
|
247
|
+
max-width: calc(var(--col) + 72px);
|
|
248
248
|
margin: 48px auto 0;
|
|
249
249
|
aspect-ratio: auto;
|
|
250
250
|
background: none;
|
|
@@ -730,6 +730,19 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
|
|
|
730
730
|
}
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
+
/* A breakout decided at build time from the image's known size. It holds the
|
|
734
|
+
figure at its final width before the image loads and before the script
|
|
735
|
+
measures; the script then takes over (and removes .fig-wide). Only where the
|
|
736
|
+
article column is a full 740px with room on both sides. */
|
|
737
|
+
@media (min-width: 1348px) {
|
|
738
|
+
.post-reading-page .markdown-body .fig-wide {
|
|
739
|
+
margin-left: calc(-1 * var(--wl, 0px));
|
|
740
|
+
margin-right: calc(-1 * var(--wr, 0px));
|
|
741
|
+
max-width: none;
|
|
742
|
+
width: auto;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
733
746
|
.toc {
|
|
734
747
|
font-size: var(--fs-small);
|
|
735
748
|
line-height: 1.5;
|
package/app/styles/shell.css
CHANGED
|
@@ -37,21 +37,28 @@ img,svg{max-width:100%}
|
|
|
37
37
|
.sh-header{position:sticky;top:0;z-index:40;background:var(--paper);border-bottom:1px solid transparent;transition:border-color .2s ease}
|
|
38
38
|
.sh-header.is-scrolled{border-bottom-color:var(--rule)}
|
|
39
39
|
.sh-bar{max-width:var(--shell);margin:0 auto;height:var(--hdr);padding:0 32px;display:flex;align-items:center;gap:28px}
|
|
40
|
-
.sh-word{font:600 var(--fs-lead)/1 var(--serif);color:var(--ink);text-decoration:none;white-space:nowrap;padding:8px 0}
|
|
40
|
+
.sh-word{display:inline-flex;align-items:center;gap:10px;font:600 var(--fs-lead)/1 var(--serif);color:var(--ink);text-decoration:none;white-space:nowrap;padding:8px 0}
|
|
41
|
+
.sh-mark{display:inline-flex;flex:none}
|
|
42
|
+
.sh-mark svg{display:block;height:26px;width:auto}
|
|
41
43
|
.sh-nav{display:flex;gap:4px;margin-left:auto}
|
|
42
44
|
.sh-nav a{font-size:15px;color:var(--soft);text-decoration:none;padding:8px 12px;border-radius:var(--r);transition:color .15s ease,background-color .15s ease}
|
|
43
45
|
.sh-nav a:hover{color:var(--ink);background:var(--tint)}
|
|
44
46
|
.sh-nav a[aria-current="page"],.sh-nav a[aria-current="true"]{color:var(--blue);font-weight:600}
|
|
45
47
|
.sh-tools{display:flex;align-items:center;gap:8px}
|
|
46
|
-
.sh-search{display:inline-flex;align-items:center;gap:
|
|
47
|
-
.sh-search:hover{
|
|
48
|
+
.sh-search{display:inline-flex;align-items:center;gap:6px;height:38px;padding:8px 12px;border:0;background:transparent;color:var(--soft);border-radius:var(--r);font:400 15px/1 var(--sans);cursor:pointer;appearance:none;transition:color .15s ease,background-color .15s ease}
|
|
49
|
+
.sh-search:hover{color:var(--ink);background:var(--tint)}
|
|
50
|
+
.sh-search:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
|
48
51
|
.sh-search svg,.sh-icon svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
49
|
-
.sh-search
|
|
52
|
+
.sh-search svg{width:16px;height:16px}
|
|
53
|
+
.sh-search .lbl{text-align:left}
|
|
54
|
+
/* The shortcut hint only where there is a keyboard and a precise pointer. */
|
|
55
|
+
.sh-search kbd{display:none;margin-left:4px;border:0;padding:0;background:none;font:400 12px/1 var(--sans);color:var(--muted);opacity:.7}
|
|
56
|
+
@media (hover:hover) and (pointer:fine){.sh-search kbd{display:inline}}
|
|
50
57
|
kbd{font:500 12px/1 var(--sans);color:var(--muted);border:1px solid var(--rule);border-bottom-width:2px;border-radius:6px;padding:3px 6px;background:var(--paper)}
|
|
51
|
-
.sh-search .kbd-mod{display:inline
|
|
58
|
+
.sh-search .kbd-mod{display:inline}
|
|
52
59
|
.sh-search .kbd-other{display:none}
|
|
53
60
|
html[data-mod="other"] .sh-search .kbd-mac{display:none}
|
|
54
|
-
html[data-mod="other"] .sh-search .kbd-other{display:inline
|
|
61
|
+
html[data-mod="other"] .sh-search .kbd-other{display:inline}
|
|
55
62
|
.sh-search-m{color:var(--soft);opacity:1}
|
|
56
63
|
.sh-search-m svg{opacity:1}
|
|
57
64
|
.sh-icon{display:inline-grid;place-items:center;width:40px;height:40px;border:0;background:transparent;color:var(--soft);border-radius:var(--r);cursor:pointer;transition:background-color .15s ease,color .15s ease}
|
package/bin/garden.mjs
CHANGED
|
@@ -77,7 +77,7 @@ function ensureLink(from, to) {
|
|
|
77
77
|
function ensurePublic() {
|
|
78
78
|
const dest = path.join(siteRoot, "public");
|
|
79
79
|
fs.mkdirSync(dest, { recursive: true });
|
|
80
|
-
for (const name of ["favicon.svg", "og.png"]) {
|
|
80
|
+
for (const name of ["favicon.svg", "apple-touch-icon.png", "og.png"]) {
|
|
81
81
|
const from = path.join(gardenPackageRoot, "public", name);
|
|
82
82
|
const to = path.join(dest, name);
|
|
83
83
|
if (fs.existsSync(from) && !fs.existsSync(to)) fs.copyFileSync(from, to);
|
|
@@ -355,6 +355,7 @@ function help() {
|
|
|
355
355
|
garden new 创建文章草稿
|
|
356
356
|
garden embed 拉取外链摘要
|
|
357
357
|
garden links 重新拉取外链图标(build 时会自动做)
|
|
358
|
+
garden images 读取新的远程图片尺寸,写入 image-sizes.json(build 时会自动做)
|
|
358
359
|
garden init [目录] 创建只含稿和配置的新站点
|
|
359
360
|
garden check <文章.md> 检查一篇博文的硬性结构
|
|
360
361
|
garden ui <子命令> Intelligent UI:scan、check、apply、prompt、schema、catalog
|
|
@@ -382,11 +383,18 @@ switch (command) {
|
|
|
382
383
|
case "build":
|
|
383
384
|
// Favicons are refetched on every build; failures fall back to link-assets.json.
|
|
384
385
|
if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/link-assets.mjs");
|
|
386
|
+
// Remote image sizes: only new images are probed; the rest come from image-sizes.json.
|
|
387
|
+
if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/image-sizes.mjs");
|
|
385
388
|
runVinext(["build", ...rest], { copyDist: true });
|
|
389
|
+
// Pages for old addresses listed under redirects in site.config.yaml (static export only).
|
|
390
|
+
if (process.env.STATIC_EXPORT === "1") runNode("scripts/redirects.mjs");
|
|
386
391
|
break;
|
|
387
392
|
case "links":
|
|
388
393
|
runNode("scripts/link-assets.mjs");
|
|
389
394
|
break;
|
|
395
|
+
case "images":
|
|
396
|
+
runNode("scripts/image-sizes.mjs");
|
|
397
|
+
break;
|
|
390
398
|
case "start":
|
|
391
399
|
runVinext(["start", ...rest]);
|
|
392
400
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hd-garden",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"bin",
|
|
28
28
|
"build",
|
|
29
29
|
"public/favicon.svg",
|
|
30
|
+
"public/apple-touch-icon.png",
|
|
30
31
|
"public/og.png",
|
|
31
32
|
"public/images",
|
|
32
33
|
"scripts",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"fuse.js": "^7.5.0",
|
|
82
83
|
"github-slugger": "^2.0.0",
|
|
83
84
|
"gray-matter": "^4.0.3",
|
|
85
|
+
"image-size": "^2.0.4",
|
|
84
86
|
"katex": "^0.16.47",
|
|
85
87
|
"marked": "^16.4.2",
|
|
86
88
|
"mdast-util-from-markdown": "^2.1.0",
|
|
Binary file
|
package/public/favicon.svg
CHANGED
|
@@ -1,4 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="
|
|
2
|
-
<rect width="256" height="256" fill="#f8f8f6"/>
|
|
3
|
-
<path fill="#202124" d="M216,208H168V184h4a68.07,68.07,0,0,0,68-68,28,28,0,0,0-56,0,12,12,0,0,1-12,12h-4V56a40,40,0,0,0-80,0V88H84A12,12,0,0,1,72,76a28,28,0,0,0-56,0,68.07,68.07,0,0,0,68,68h4v64H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16ZM96,128H84A52.06,52.06,0,0,1,32,76a12,12,0,0,1,24,0,28,28,0,0,0,28,28H96a8,8,0,0,0,8-8V56a24,24,0,0,1,48,0v80a8,8,0,0,0,8,8h12a28,28,0,0,0,28-28,12,12,0,0,1,24,0,52.06,52.06,0,0,1-52,52H160a8,8,0,0,0-8,8v32H104V136A8,8,0,0,0,96,128Z"/>
|
|
4
|
-
</svg>
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1.5 1.25 28 28" fill="none" stroke="#1B1A18" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><style>@media (prefers-color-scheme: dark){svg{stroke:#EEECE7}}</style><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>
|
|
@@ -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
|
+
}
|
|
@@ -11,6 +11,8 @@ 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
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";
|
|
14
16
|
import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
|
|
15
17
|
import { coverImageSet, loadSharp } from "./cover-images.mjs";
|
|
16
18
|
|
|
@@ -531,6 +533,40 @@ const thoughts = readMarkdownTree(path.join(contentRoot, "thoughts"), "thought")
|
|
|
531
533
|
(a, b) => Date.parse(b.date) - Date.parse(a.date),
|
|
532
534
|
);
|
|
533
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
|
+
|
|
534
570
|
assertUnique(posts, (item) => item.path, "博文路径");
|
|
535
571
|
assertUnique(columns, (item) => `${item.column}/${item.path}`, "专栏路径");
|
|
536
572
|
assertUnique(
|
|
@@ -620,6 +656,7 @@ export const columns = ${JSON.stringify(columns, null, 2)} as ContentEntry[];
|
|
|
620
656
|
export const thoughts = ${JSON.stringify(thoughts, null, 2)} as ContentEntry[];
|
|
621
657
|
export const a2WordCounts = ${JSON.stringify(a2WordCounts, null, 2)} as Record<string, number>;
|
|
622
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)};
|
|
623
660
|
`;
|
|
624
661
|
|
|
625
662
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
@@ -634,6 +671,8 @@ export const githubUrl = ${JSON.stringify(`https://github.com/${siteConfig.autho
|
|
|
634
671
|
export const searchIndexUrl = ${JSON.stringify(`${basePath}/search-index.json?v=${contentHash}`)};
|
|
635
672
|
// Every body as plain text, fetched in the background after load; the name changes with the text.
|
|
636
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)))};
|
|
637
676
|
// Full URLs to this address render as internal links.
|
|
638
677
|
export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath })};
|
|
639
678
|
`,
|
|
@@ -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
|
@@ -94,14 +94,14 @@ export async function assertPublicDns(hostname) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function guardedFetch(fetcher) {
|
|
97
|
+
export function guardedFetch(fetcher) {
|
|
98
98
|
return async (url, init) => {
|
|
99
99
|
await assertPublicDns(new URL(url).hostname);
|
|
100
100
|
return fetcher(url, init);
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
async function pool(items, size, worker) {
|
|
104
|
+
export async function pool(items, size, worker) {
|
|
105
105
|
const queue = [...items];
|
|
106
106
|
const runners = Array.from({ length: Math.min(size, queue.length) }, async () => {
|
|
107
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
|
+
}
|
|
@@ -4,6 +4,11 @@ description: 文章、随想和专栏。
|
|
|
4
4
|
locale: zh_CN
|
|
5
5
|
# 站点的公开地址。文章里指向这个地址的完整网址会按站内链接显示。
|
|
6
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
|
|
7
12
|
|
|
8
13
|
author:
|
|
9
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
|
+
}
|