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.
@@ -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: 920px) calc(100vw - 40px), 880px" priority />
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: 920px) calc(100vw - 40px), 880px" priority />
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 CSS sizing: natural size, capped at the column.
106
- image.style.removeProperty("width");
107
- if (!image.naturalWidth) return;
108
- const attrWidth = Number(image.getAttribute("width")) || 0;
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: image.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: image.naturalWidth, attrWidth, widened: breakout.left > 0 });
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: MarkdownImage,
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} {...props} />
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">{siteConfig.name}</Link>
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">搜索文章</span>
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="搜索文章">
@@ -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: "/favicon.svg",
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,
@@ -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: { className: ["fig"] } },
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
- max-width: 880px;
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;
@@ -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:10px;height:38px;padding:0 8px 0 12px;border:1px solid var(--rule);background:var(--card);color:var(--muted);border-radius:var(--r);font:400 var(--fs-small)/1 var(--sans);cursor:pointer;appearance:none;transition:border-color .15s ease,color .15s ease}
47
- .sh-search:hover{border-color:var(--rule-strong);color:var(--soft)}
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 .lbl{min-width:84px;text-align:left}
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-block;min-width:2.5em;text-align:end}
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-block}
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.2",
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
@@ -1,4 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
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
+ }
@@ -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, "&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
+ }
@@ -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
@@ -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
+ }