Repository Wiki
LyraVoid/Mizuki

图片增强与灯箱(响应式图片、自动网格、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 一行一张图地写,页面会得到一列高度不齐、宽度拉满的大图,阅读体验差。该能力要解决三个问题:

  1. 自动网格:作者无需学习新语法,只要连续写多张图片(段落之间可以有换行),remarkAutoImageGrid 就会在 AST 层面把它们打包成与显式 :::grid 指令完全相同的结构。零心智负担、零侵入。
  2. 响应式等比裁切:ImageGridComponent 把每张图包进 <figure>,通过 CSS 自定义属性(--image-grid-columns、--image-grid-aspect-ratio、--image-grid-fit)控制列数与统一宽高比,让不同尺寸的原始图片在网格中呈现为整齐的瓦片。
  3. 灯箱:每个画廊获得一个全局唯一的 data-fancybox 分组 ID,图片链接指向原图并带 data-caption,Fancybox 运行时据此提供同组翻页、标题展示与放大浏览。

设计上的关键取舍是**"自动改写输出与手写指令等价"**:自动插件不直接生成 HTML,而是生成 containerDirective 节点,把渲染责任完全交给同一个 rehype 组件。这保证了显式和隐式两条路径只有一处渲染实现,属性解析与容错逻辑不会分叉。

Architecture

Loading diagram...

架构要点:

  • 两阶段接力: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 上做一次线性扫描,这保证了插件只处理文章正文级别的图片序列,不会误伤嵌套在列表、引用块里的图片。

javascript
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

逐步解读这段扫描算法:

  1. 参数钳制:minImages 至少为 2、maxColumns 至少为 1。即使配置写成 0 或负数,也不会出现"单图成网格"或"零列"的退化情形。
  2. 双指针线性扫描:外层 index 指向当前未处理的顶层节点,内层 cursor 向后贪婪收集一个"图片段落数组" run,同时累计 imageCount。一旦遇到非纯图片段落(imageCountInParagraph 返回 0)就停止。
  3. 阈值判定:只有当累计图片数 ≥ minImages 才把整段 run 替换为 containerDirective;否则原样保留节点并前移一格。注意这里不会部分回退——不达标的节点保持原样,不会被打包。
  4. 列数自适应:columns 取 min(imageCount, maxColumns),即 2 张图就是 2 列,超过上限则封顶。这是"列数与内容量匹配"的直观体验来源。
  5. 原地重写:最终用 output 数组整体替换 tree.children,避免在遍历中修改正在遍历的数组。

段落纯度判定

能否被算作"图片段落"由 imageCountInParagraph 决定,它同时接受裸图片和"链接包裹的单图"两种形态:

javascript
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() 为空就跳过,因此 ![a](x.png)\n![b](y.png) 与同一行的写法等价。
  • 链接包裹图片被认可:[![alt](src)](href) 是常见的"可点击图片"写法,只要链接内恰好只有一个子节点且是 image 就计入。多个子节点(例如链接里混有文字)则整段判定失败。
  • 全有或全无:任何非图片、非空文本的子节点(文字说明、行内代码、强调等)都会让函数立即 return 0,即"混有文字的段落不会被改写"——这正是源码注释里明确声明的不变量。

指令渲染(rehype 阶段)

ImageGridComponent(properties, children) 是被 rehype 指令桥接器调用的组件工厂。它先用深度优先的 findImages 收集指令内所有 img 元素(允许图片散布在子块中),再逐个包装:

javascript
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 标记。

时序:一篇文章中的完整处理链

Loading diagram...

构建期(Astro SSG)只负责到"产出带属性的静态 HTML"为止;data-no-swup 与 data-fancybox 都是为浏览器端运行时预留的契约。

插件注册(astro.config.mjs)

两个插件在 astro.config.mjs 中按固定顺序挂载,且自动网格是条件启用的:

javascript
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)

OptionTypeDefaultDescription
enableboolean—(由 src/config/markdownConfig.ts 决定)是否启用 remarkAutoImageGrid;关闭后仅显式 :::grid 指令可用
minImagesnumber2触发自动打包所需的最少连续图片数;实现中会被钳制为 Math.max(2, value),即最低 2
maxColumnsnumber4自动网格的最大列数;实现中会被钳制为 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 解析)

AttributeTypeDefaultDescription
columnsstring (整数)3网格列数;Number.parseInt 解析,仅接受 1–6 的整数,越界或非法时回退默认值
aspectstring ("W / H")"16 / 10"瓦片统一宽高比;必须匹配 数字 / 数字(允许小数与空白),宽高必须为正,否则回退默认值
fitstring"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

自动网格(隐式写法)

在文章中连续书写多张图片,段落之间只允许空白,即会被自动打包:

markdown
1![](./screenshots/home.png) 2 3[![](./screenshots/post-list.png)](./screenshots/post-list.png) 4 5[![](./screenshots/settings.png)](./screenshots/settings.png)

满足 imageCount >= 2(默认 minImages)后,构建产物为:

html
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 指令(自定义参数)

markdown
1:::grid{columns="4" aspect="1 / 1" fit="contain"} 2![](a.png "标签页 A") 3![](b.png "标签页 B") 4![](c.png "标签页 C") 5![](d.png "标签页 D") 6:::

columns="4" 在 1–6 范围内生效;aspect="1 / 1" 匹配 W / H 正则生成正方形瓦片;fit="contain" 使图片完整显示不被裁切。三个属性非法时分别回退 3、"16 / 10"、"cover"。

Source: rehype-component-image-grid.mjs

在 Astro 配置中关闭自动识别(保留显式指令)

javascript
...(markdownConfig.autoImageGrid.enable ? [[remarkAutoImageGrid, markdownConfig.autoImageGrid]] : []),

Source: astro.config.mjs