图片增强与灯箱(响应式图片、自动网格、Fancybox)
Mizuki 在统一的 Markdown/MDX 渲染管线中为图片提供三层增强能力:自动图片网格(把连续的纯图片段落自动变成响应式画廊)、显式 :::grid 网格指令渲染,以及基于 Fancybox data-* 属性的灯箱集成。本页覆盖这三层机制的源码实现、管线接线方式、配置项与边界行为。
Purpose and Scope
本页覆盖以下内容:
remarkAutoImageGrid插件:如何在 mdast(Markdown 抽象语法树)层面把"连续多张纯图片段落"自动改写为grid容器指令。ImageGridComponent(rehype 组件):如何把:::grid指令渲染为响应式画廊 DOM,并注入 Fancybox 灯箱所需的data-fancybox等属性。- 两个插件在
astro.config.mjs中的注册顺序与条件启用逻辑。 - 网格指令的全部属性(
columns/aspect/fit)的解析规则、默认值与容错策略。 - 失败模式、边界情况(混合文字段落、非法属性值、空网格)与构建期行为。
刻意留给兄弟页面的话题:
- Markdown 管线整体(callouts、KaTeX、Mermaid、PlantUML、GitHub 卡片等插件的组织方式),见「Markdown 渲染管线」相关页面。
- 站点级 Markdown 配置文件
src/config/markdownConfig.ts的完整字段说明,本页只讨论与autoImageGrid相关的部分。 - Fancybox 前端运行时(脚本加载、主题、手势)的 UI 层实现——本页只覆盖"构建期生成哪些 HTML 属性",因为 Fancybox 运行时由前端脚本消费这些属性,而其加载逻辑不在本次审阅的源码范围内。
Overview
Mizuki 的博客文章经常包含多张截图。如果作者按普通 Markdown 一行一张图地写,页面会得到一列高度不齐、宽度拉满的大图,阅读体验差。该能力要解决三个问题:
- 自动网格:作者无需学习新语法,只要连续写多张图片(段落之间可以有换行),
remarkAutoImageGrid就会在 AST 层面把它们打包成与显式:::grid指令完全相同的结构。零心智负担、零侵入。 - 响应式等比裁切:
ImageGridComponent把每张图包进<figure>,通过 CSS 自定义属性(--image-grid-columns、--image-grid-aspect-ratio、--image-grid-fit)控制列数与统一宽高比,让不同尺寸的原始图片在网格中呈现为整齐的瓦片。 - 灯箱:每个画廊获得一个全局唯一的
data-fancybox分组 ID,图片链接指向原图并带data-caption,Fancybox 运行时据此提供同组翻页、标题展示与放大浏览。
设计上的关键取舍是**"自动改写输出与手写指令等价"**:自动插件不直接生成 HTML,而是生成 containerDirective 节点,把渲染责任完全交给同一个 rehype 组件。这保证了显式和隐式两条路径只有一处渲染实现,属性解析与容错逻辑不会分叉。
Architecture
架构要点:
- 两阶段接力:
remarkAutoImageGrid工作在 remark(mdast)阶段,只做结构识别与改写;ImageGridComponent工作在 rehype(hast)阶段,负责最终 DOM。二者通过containerDirective: grid这一中间表示解耦。 - 单一渲染出口:无论图片网格是自动生成的还是作者手写的
:::grid,最终都经过ImageGridComponent同一个函数渲染,因此columns/aspect/fit的默认值与容错行为全站一致。 - 构建期与运行时分工:插件在 Astro 构建期(SSG)运行,只产出带
data-fancybox属性的静态 HTML;灯箱交互完全由浏览器端的 Fancybox 运行时接管,构建产物不包含任何交互逻辑。
Source: astro.config.mjs
Core Flow
自动网格识别(remark 阶段)
remarkAutoImageGrid 的入口是一个标准的 remark 插件工厂:接收 options,返回作用在 tree 上的 visitor。它不递归遍历整棵树,而是只在顶层 tree.children 上做一次线性扫描,这保证了插件只处理文章正文级别的图片序列,不会误伤嵌套在列表、引用块里的图片。
1export function remarkAutoImageGrid(options = {}) {
2 const minImages = Math.max(2, options.minImages ?? 2);
3 const maxColumns = Math.max(1, options.maxColumns ?? 4);
4
5 return (tree) => {
6 if (!Array.isArray(tree.children)) return;
7 const output = [];
8 for (let index = 0; index < tree.children.length; ) {
9 const run = [];
10 let imageCount = 0;
11 let cursor = index;
12 while (cursor < tree.children.length) {
13 const node = tree.children[cursor];
14 const count = imageCountInParagraph(node);
15 if (count === 0) break;
16 run.push(node);
17 imageCount += count;
18 cursor++;
19 }
20
21 if (imageCount >= minImages) {
22 output.push({
23 type: "containerDirective",
24 name: "grid",
25 attributes: {
26 columns: String(Math.min(imageCount, maxColumns)),
27 },
28 children: run,
29 });
30 index = cursor;
31 } else {
32 output.push(tree.children[index]);
33 index++;
34 }
35 }
36 tree.children = output;
37 };
38}Source: remark-auto-image-grid.mjs
逐步解读这段扫描算法:
- 参数钳制:
minImages至少为 2、maxColumns至少为 1。即使配置写成 0 或负数,也不会出现"单图成网格"或"零列"的退化情形。 - 双指针线性扫描:外层
index指向当前未处理的顶层节点,内层cursor向后贪婪收集一个"图片段落数组"run,同时累计imageCount。一旦遇到非纯图片段落(imageCountInParagraph返回 0)就停止。 - 阈值判定:只有当累计图片数 ≥
minImages才把整段run替换为containerDirective;否则原样保留节点并前移一格。注意这里不会部分回退——不达标的节点保持原样,不会被打包。 - 列数自适应:
columns取min(imageCount, maxColumns),即 2 张图就是 2 列,超过上限则封顶。这是"列数与内容量匹配"的直观体验来源。 - 原地重写:最终用
output数组整体替换tree.children,避免在遍历中修改正在遍历的数组。
段落纯度判定
能否被算作"图片段落"由 imageCountInParagraph 决定,它同时接受裸图片和"链接包裹的单图"两种形态:
1function imageCountInParagraph(node) {
2 if (node?.type !== "paragraph") return 0;
3 let count = 0;
4 for (const child of node.children ?? []) {
5 if (child.type === "text" && child.value.trim() === "") continue;
6 if (child.type === "image") {
7 count++;
8 continue;
9 }
10 if (
11 child.type === "link" &&
12 child.children?.length === 1 &&
13 child.children[0].type === "image"
14 ) {
15 count++;
16 continue;
17 }
18 return 0;
19 }
20 return count;
21}Source: remark-auto-image-grid.mjs
这段函数体现了三个刻意的设计决策:
- 空白文本不破坏判定:Markdown 中图片之间的换行/空格会生成
text节点,只要value.trim()为空就跳过,因此\n与同一行的写法等价。 - 链接包裹图片被认可:
[](href)是常见的"可点击图片"写法,只要链接内恰好只有一个子节点且是image就计入。多个子节点(例如链接里混有文字)则整段判定失败。 - 全有或全无:任何非图片、非空文本的子节点(文字说明、行内代码、强调等)都会让函数立即
return 0,即"混有文字的段落不会被改写"——这正是源码注释里明确声明的不变量。
指令渲染(rehype 阶段)
ImageGridComponent(properties, children) 是被 rehype 指令桥接器调用的组件工厂。它先用深度优先的 findImages 收集指令内所有 img 元素(允许图片散布在子块中),再逐个包装:
1const items = images.map((image) => {
2 const src = image.properties?.src;
3 const alt = String(image.properties?.alt ?? "");
4 const title = String(image.properties?.title ?? alt);
5
6 return h("figure", { class: "image-grid__item" }, [
7 h(
8 "a",
9 {
10 class: "image-grid__link no-styling",
11 href: src,
12 "data-fancybox": galleryId,
13 "data-no-swup": "true",
14 "data-caption": title,
15 },
16 [image],
17 ),
18 title ? h("figcaption", { class: "image-grid__caption" }, title) : null,
19 ]);
20});
21
22return h(
23 "div",
24 {
25 class: "image-grid",
26 "data-columns": String(columns),
27 style: `--image-grid-columns: ${columns}; --image-grid-aspect-ratio: ${aspectRatio}; --image-grid-fit: ${fit};`,
28 },
29 items,
30);Source: rehype-component-image-grid.mjs
逐点说明每个属性为什么存在:
href: src:链接指向原图本身,保证在不加载 JS(无 Fancybox)时仍然是可用的降级——点击直接打开原图。data-fancybox: galleryId:Fancybox 以同名data-fancybox值把元素分为一组,组内可翻页。galleryId由模块级计数器gallerySequence++生成(形如image-grid-0、image-grid-1),同一页面内的多个画廊互不串组。data-no-swup: "true":站点使用 swup 做无刷新页面切换,该属性告诉 swup 不要拦截此链接的点击,避免灯箱打开被路由系统劫持。data-caption: title:标题取图片title,缺省回退到alt,Fancybox 在放大视图底部展示它。figcaption条件渲染:title为空字符串时输出null,hastscript 会跳过null子节点,因此无标题图片不会产生空元素。- CSS 变量驱动样式:
columns、aspectRatio、fit不写成内联几何样式,而是通过--image-grid-columns等自定义属性下发给样式层,媒体查询可在小屏幕上覆盖列数而不需要 JS。data-columns同时作为易读的 DOM 标记。
时序:一篇文章中的完整处理链
构建期(Astro SSG)只负责到"产出带属性的静态 HTML"为止;data-no-swup 与 data-fancybox 都是为浏览器端运行时预留的契约。
插件注册(astro.config.mjs)
两个插件在 astro.config.mjs 中按固定顺序挂载,且自动网格是条件启用的:
1import { ImageGridComponent } from "./src/plugins/rehype-component-image-grid.mjs";
2// ...
3import { remarkAutoImageGrid } from "./src/plugins/remark-auto-image-grid.mjs";
4// ...
5...(markdownConfig.autoImageGrid.enable
6 ? [[remarkAutoImageGrid, markdownConfig.autoImageGrid]]
7 : []),Source: astro.config.mjs
Source: astro.config.mjs
要点:
- 配置对象直接作为插件 options:
markdownConfig.autoImageGrid整体传给remarkAutoImageGrid,因此enable字段仅作开关,minImages/maxColumns等字段会被插件读取(多余的enable字段会被忽略)。 - 关闭自动网格不影响显式指令:
enable: false时插件完全不注入,但ImageGridComponent仍然注册,手写:::grid照常工作。这是一个重要的能力分层——"自动识别"可关,"网格本身"始终可用。
Configuration Options
自动网格插件(markdownConfig.autoImageGrid)
| Option | Type | Default | Description |
|---|---|---|---|
enable | boolean | —(由 src/config/markdownConfig.ts 决定) | 是否启用 remarkAutoImageGrid;关闭后仅显式 :::grid 指令可用 |
minImages | number | 2 | 触发自动打包所需的最少连续图片数;实现中会被钳制为 Math.max(2, value),即最低 2 |
maxColumns | number | 4 | 自动网格的最大列数;实现中会被钳制为 Math.max(1, value),实际列数为 min(图片数, maxColumns) |
Source: remark-auto-image-grid.mjs
Source: astro.config.mjs
src/config/markdownConfig.ts 是站点级配置入口(README 中标注其职责为 Wiki Links、自动图片网格与 PlantUML);本页只覆盖 autoImageGrid 分支。
:::grid 指令属性(由 ImageGridComponent 解析)
| Attribute | Type | Default | Description |
|---|---|---|---|
columns | string (整数) | 3 | 网格列数;Number.parseInt 解析,仅接受 1–6 的整数,越界或非法时回退默认值 |
aspect | string ("W / H") | "16 / 10" | 瓦片统一宽高比;必须匹配 数字 / 数字(允许小数与空白),宽高必须为正,否则回退默认值 |
fit | string | "cover" | 图片填充方式,仅 "contain" 被识别为切换值,其余任何输入(含拼写错误)都回退 "cover" |
Source: rehype-component-image-grid.mjs
三个解析函数共同体现一种宽进严出的容错哲学:属性来自 Markdown 文本,作者写错很常见,因此一律"静默回退到安全默认值"而不是抛错让构建失败。
API Reference
remarkAutoImageGrid(options = {})
描述:remark 插件工厂。把顶层连续的纯图片段落(累计 ≥ minImages 张)改写为 containerDirective 节点,name 为 "grid",attributes.columns 为自适应列数。
Parameters:
options(object, 可选):{ minImages?: number, maxColumns?: number };其余字段(如enable)会被忽略。
Returns: (tree: Root) => void —— 直接原地修改 tree.children。
Throws: 无显式抛出;对非数组 tree.children 直接返回。
Source: remark-auto-image-grid.mjs
imageCountInParagraph(node)
描述:判定一个顶层节点是否为"纯图片段落"并返回图片张数。
Parameters:
node(object):mdast 节点。
Returns: number —— 非段落返回 0;纯图片段落返回图片数;段落内含任何非空白文本或其它内联节点时返回 0。
Source: remark-auto-image-grid.mjs
ImageGridComponent(properties, children)
描述:rehype 指令组件。把 :::grid 容器指令渲染为 div.image-grid 画廊,内部为若干 figure.image-grid__item,每张图片包在带 Fancybox 分组属性的锚点里。
Parameters:
properties(Record<string, unknown>):指令属性,支持columns、aspect、fit。children(hast RootContent[]):指令子节点,其中所有img元素会被递归收集。
Returns: hast Element —— 正常时为 div.image-grid;当指令内找不到任何 img 时返回一个 class: "hidden" 的 div,内容为英文提示 "Invalid image grid. (Use a block directive containing one or more Markdown images: ":::grid ... :::")"。
Source: rehype-component-image-grid.mjs
parseColumns(value) / parseAspectRatio(value) / parseFit(value)
描述:三个属性解析器,均带默认回退。
parseColumns:parseInt后必须为 1–6 的整数,否则返回3。parseAspectRatio:正则匹配^\s*(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*$,宽高须为正数,返回规范化的"W / H"字符串;不匹配返回"16 / 10"。parseFit:仅当值严格等于"contain"时返回"contain",否则返回"cover"。
Returns: 均为归一化后的字符串/数字,调用方无需再做校验。
Source: rehype-component-image-grid.mjs
Failure Modes, Edge Cases & Concurrency
构建期边界
- 空网格:
:::grid里没有任何图片时,ImageGridComponent返回隐藏的div并附带提示文本。选择"渲染提示而非报错"意味着构建不会因为一个写错的指令而失败——对博客模板这种"作者即用户"的场景,这是对可用性的优先取舍。 - 混入文字的段落不参与自动网格:
imageCountInParagraph的全有或全无判定(遇到非空白文本/其它节点立即return 0)保证带图注的图片行保持原样。作者想给某张图配文字说明时,该图自动退出网格,不会被误打包。 - 嵌套图片不受影响:扫描只发生在
tree.children顶层,列表项、引用块中的图片序列不会被改写,避免破坏作者刻意维持的嵌套结构。 - 不达标序列保留原样:连续图片数小于
minImages时,扫描循环走else分支逐节点原样输出,不会出现"半个网格"。 - 非法属性静默回退:
columns="9"、columns="abc"、aspect="4:3"(错误分隔符)、fit="fills"等错误输入都会回退默认值,页面仍可渲染。代价是作者可能察觉不到配置没生效——需要对照渲染后的style属性排查。
画廊 ID 与全局状态
gallerySequence是模块级可变计数器,在同一个 Node 进程内持续递增,保证同进程构建的所有画廊 ID 唯一。由于 Astro SSG 构建是单进程顺序渲染,这是安全的;但在并发渲染或热重载复用模块的极端情况下,ID 会持续增长——好在 ID 只要求"页面内分组唯一",不要求全局稳定,因此不会造成功能性问题。- 画廊分组完全由
data-fancybox值决定。Fancybox 运行时把页面上同组元素集合为可翻页相册;若两个画廊意外共用了 ID(当前实现下不会发生),会出现跨画廊翻页。
浏览器端契约
data-no-swup="true"与href指向原图构成双重保险:即使 Fancybox 脚本未加载或被禁用,点击图片仍以原生链接行为打开原图,不产生死链。alt与title的回退关系(title ?? alt)意味着未写 title 的图片在灯箱与figcaption中都会展示 alt 文本,无障碍标签与视觉标题保持一致。
Performance & Operational Notes
- 线性扫描复杂度:
remarkAutoImageGrid对每个顶层节点做一次imageCountInParagraph,整体 O(顶层节点数 + 段落子节点数),对任意长度的文章都是线性开销,构建期成本可忽略。 - 零运行时 JS:网格布局完全由构建期写入的 CSS 自定义属性驱动,浏览器端只需 Fancybox 自身脚本,没有额外的图片网格脚本。
- 构建可观测性:所有异常输入都以"回退默认值"或"隐藏提示 div"形式消化,不会在构建日志中产生警告。若线上页面出现非预期布局,排查顺序应为:① 检查
markdownConfig.autoImageGrid.enable;② 检查段落是否混有非空白文本;③ 检查渲染产物div.image-grid的style与data-columns是否符合预期。
Extension Points
- 新增指令属性:在
ImageGridComponent中仿照parseFit增加一个"解析 + 默认回退"函数,并把结果追加进style模板字符串即可;由于属性以 CSS 变量下发,样式层可在不动插件的情况下消费新变量。 - 调整自动识别范围:当前仅扫描顶层。若需要让列表/引用内的图片序列也自动成网格,需要把
remark-auto-image-grid.mjs的外层循环改为对子树递归调用同一扫描函数,并注意保持"混文字不打包"的不变量。 - 更换灯箱实现:构建期产出的契约只有三个
data-*属性与href;替换 Fancybox 只需要新的运行时识别同一契约,插件代码无需改动。
Usage Examples
自动网格(隐式写法)
在文章中连续书写多张图片,段落之间只允许空白,即会被自动打包:
1
2
3[](./screenshots/post-list.png)
4
5[](./screenshots/settings.png)满足 imageCount >= 2(默认 minImages)后,构建产物为:
1<div class="image-grid" data-columns="2"
2 style="--image-grid-columns: 2; --image-grid-aspect-ratio: 16 / 10; --image-grid-fit: cover;">
3 <figure class="image-grid__item">
4 <a class="image-grid__link no-styling" href="…"
5 data-fancybox="image-grid-0" data-no-swup="true" data-caption="…">
6 <img … />
7 </a>
8 <figcaption class="image-grid__caption">…</figcaption>
9 </figure>
10 …
11</div>(上图为渲染结构示意,… 表示省略的具体路径与文案;该结构由下方源码的 h() 调用产出。)
Source: rehype-component-image-grid.mjs
显式 :::grid 指令(自定义参数)
1:::grid{columns="4" aspect="1 / 1" fit="contain"}
2
3
4
5
6:::columns="4" 在 1–6 范围内生效;aspect="1 / 1" 匹配 W / H 正则生成正方形瓦片;fit="contain" 使图片完整显示不被裁切。三个属性非法时分别回退 3、"16 / 10"、"cover"。
Source: rehype-component-image-grid.mjs
在 Astro 配置中关闭自动识别(保留显式指令)
...(markdownConfig.autoImageGrid.enable
? [[remarkAutoImageGrid, markdownConfig.autoImageGrid]]
: []),Source: astro.config.mjs
Related Links
- remark-auto-image-grid.mjs — 自动图片网格 remark 插件
- rehype-component-image-grid.mjs — 网格指令 rehype 渲染组件
- astro.config.mjs — 插件与组件的注册位置
- src/config/markdownConfig.ts — 站点级 Markdown 配置(
autoImageGrid开关) - README.md — 功能清单中"图片增强"条目