为hexo博客添加附件卡片与沉浸式预览器

这篇文章主体是Opus 5写的,有时候真的感慨ai的方便,以前想添加这种小功能,又不会编程,就是跑到别人博客到处抄。

现在cc一装,账户一连,提出要求,看会小说回来就发现已经搞好了,甚至还写了个教程。

Man,what can I say

总之这确实是一个很重要的需求,因为我老是喜欢赛博斗蛐蛐,尤其是之前A/出bug零元购max20x账户让我爽玩了一晚上Fable5,不仅写了几个网页还生产了一堆清水文,让我好不过瘾。但是想要放到博客上就很累了,复制粘贴固然简单但是排版特别烂。

所以就有了添加这个功能的契机,那么下面就是教程了,由Opus 5撰写。


写文章的时候经常想丢一份文件给读者——一份大纲、一张表、一段源码。以前我的做法是甩一个裸链接出去,读者点一下,浏览器要么下载,要么把 Markdown 当纯文本糊在屏幕上,丑得很。

于是花了个下午做了这么个东西:文章里写一行标签,编译出来是一张附件卡片;点一下卡片,页面上浮起一层预览器,Markdown 当场渲染,PDF 内嵌,图片直接看,办公文档提示下载。全程不跳页,不依赖任何在线预览服务。

下面是成品,可以点开试试:

附件预览器测试文档Markdown 文档下载

一、整体思路

拆开看只有两层:

  • 构建期:一个 Hexo 标签插件,把 attachment 标签编译成一段带 data-* 属性的静态 HTML 卡片。类型判断、强调色、图标文字全在这一步定好,前端不用猜。
  • 运行期:一个前端脚本,把点击事件委托在 document 上,读卡片的 data-*,按 kind 分流到不同的渲染函数,塞进浮层。

附件本体放在 source/files/ 里,靠 skip_render 原样拷贝到 public/,这样前端可以 fetch 到原始文本。

目录结构如下:

1
2
3
4
5
6
7
8
9
10
blog/
├─ scripts/
│ └─ attachment.js ← 标签插件(构建期)
└─ source/
├─ css/attachment.css ← 卡片与预览器样式
├─ js/attachment.js ← 交互脚本(运行期)
├─ lib/attachment/
│ ├─ marked.js ← Markdown 渲染
│ └─ purify.min.js ← HTML 消毒
└─ files/ ← 附件本体扔这里

二、准备两个库

Markdown 渲染用 marked,渲染完的 HTML 必须过一道 DOMPurify 再进 DOM。不想吃 CDN 的不稳定,就直接从 npm 里抠出来放进 source/lib

1
2
3
4
npm i marked@^4.3.0 dompurify@^2.5.8
mkdir -p source/lib/attachment
cp node_modules/marked/lib/marked.umd.js source/lib/attachment/marked.js
cp node_modules/dompurify/dist/purify.min.js source/lib/attachment/purify.min.js

marked 4.x 用 UMD 版是因为它挂 window.marked 最省事;DOMPurify 2.x 则是为了照顾老一点的浏览器。

三、让附件目录别被渲染

这一步不做,source/files/ 下的 .md 会被 Hexo 当成页面渲染成 HTML,前端 fetch 回来的就不是原文了。改 _config.yml

1
2
3
4
5
6
skip_render:
- 'html/**'
- '404.html'
- 'font.ttf'
# 附件原文件保持原样输出,供附件预览器直接读取
- 'files/**'

四、标签插件

新建 scripts/attachment.js。Hexo 会自动加载 scripts/ 下的脚本,不用装插件、不用改 package.json

核心就是一张类型表加一段拼字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/* global hexo */
'use strict';

const urlFor = require('hexo-util').url_for;

// kind -> 前端用哪种方式预览
// tone -> 卡片强调色
// label -> 副标题里显示的类型名
const TYPE_MAP = {
md: { kind: 'markdown', tone: 'md', label: 'Markdown 文档' },
txt: { kind: 'text', tone: 'text', label: '文本文档' },
pdf: { kind: 'pdf', tone: 'pdf', label: 'PDF 文档' },
xlsx: { kind: 'office', tone: 'sheet', label: 'Excel 表格' },
pptx: { kind: 'office', tone: 'slide', label: 'PowerPoint 演示文稿' },
png: { kind: 'image', tone: 'image', label: 'PNG 图片' },
zip: { kind: 'binary', tone: 'archive', label: 'ZIP 压缩包' }
// ……docx / csv / mp4 / js / py 等等照着加
};

const FALLBACK_TYPE = { kind: 'binary', tone: 'default', label: '文件' };

function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

hexo.extend.tag.register('attachment', function (args) {
const list = (args || []).filter(a => a !== '');
if (!list.length) return '<div class="atc-card atc-card-error">附件标签缺少路径参数</div>';

const rawPath = list[0];
const ext = (list[2] || rawPath.split('.').pop()).toLowerCase();
const type = TYPE_MAP[ext] || FALLBACK_TYPE;
const title = list[1] || rawPath.split('/').pop();
const size = list[3] || '';
const href = urlFor.call(hexo, rawPath);
const sub = size ? type.label + ' · ' + size : type.label;
const badge = (ext || 'FILE').toUpperCase().slice(0, 4);

return '<div class="atc-card atc-tone-' + escapeHtml(type.tone) + '"'
+ ' data-atc-url="' + escapeHtml(href) + '"'
+ ' data-atc-name="' + escapeHtml(title) + '"'
+ ' data-atc-kind="' + escapeHtml(type.kind) + '"'
+ ' data-atc-label="' + escapeHtml(type.label) + '"'
+ ' data-atc-size="' + escapeHtml(size) + '"'
+ ' role="button" tabindex="0" aria-label="打开附件:' + escapeHtml(title) + '">'
+ '<span class="atc-edge" aria-hidden="true"></span>'
+ '<span class="atc-thumb" aria-hidden="true"><span class="atc-paper">'
+ '<span class="atc-paper-ext">' + badge + '</span></span></span>'
+ '<span class="atc-meta">'
+ '<span class="atc-title">' + escapeHtml(title) + '</span>'
+ '<span class="atc-sub">' + escapeHtml(sub) + '</span>'
+ '</span>'
+ '<span class="atc-actions">'
+ '<button type="button" class="atc-btn atc-btn-view">'
+ '<i class="fa fa-eye"></i><span class="atc-btn-text">查看</span></button>'
+ '<a class="atc-btn atc-btn-download" href="' + escapeHtml(href) + '" download rel="noopener">'
+ '<i class="fa fa-download"></i><span class="atc-btn-text">下载</span></a>'
+ '</span>'
+ '</div>';
});

两个细节值得说一句:

  • 卡片标题用 span 而不是 h4,否则 NexT 的目录会把附件标题也收进去,右侧 TOC 立刻变得莫名其妙。
  • 参数由 nunjucks 的词法分析器切分,成对引号会被自动去掉,引号内的空格会保留,所以 "28 KB" 能作为一个完整参数传进来。

五、前端脚本

source/js/attachment.js,完整版有五百多行,这里只挑骨架。第一是事件委托,这样 PJAX 换页或动态插入的卡片都不用重新绑定:

1
2
3
4
5
6
7
document.addEventListener('click', function (e) {
var card = e.target.closest && e.target.closest('.post-body .atc-card');
if (!card || card.classList.contains('atc-card-error')) return;
if (e.target.closest('.atc-btn-download')) return; // 下载按钮走默认行为
e.preventDefault();
openViewer(dataFromCard(card));
});

第二是按 kind 分流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function render(v, data, token) {
var alive = function () { return v.open && v.token === token; };
switch (data.kind) {
case 'markdown': return renderMarkdown(v, data, alive);
case 'text':
case 'code': return renderPlain(v, data, alive);
case 'pdf': return renderPdf(v, data);
case 'image': return renderImage(v, data, alive);
case 'office': return setState(v.body, {
icon: 'fa-file-word',
title: '该格式暂不支持网页预览',
desc: (data.label || '此文件') + '需要下载后使用本地办公软件打开。',
url: data.url
});
default: return setState(v.body, {
icon: 'fa-file', title: '该格式暂不支持网页预览',
desc: '可以下载后在本地打开此文件。', url: data.url
});
}
}

那个 token 是防竞态用的:连着点开好几个附件时,先发的请求可能后回来,用自增 token 判断「这次渲染还算不算数」,不算数就直接扔掉。

第三是 Markdown 分支,全篇最需要小心的地方——**marked 的输出绝不能直接 innerHTML**:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function renderMarkdown(v, data, alive) {
Promise.all([fetchText(data.url), ensureMarkdownLibs()]).then(function (out) {
if (!alive()) return;
var html = window.marked.parse(out[0].text, { gfm: true, breaks: false });
var fragment = window.DOMPurify.sanitize(html, {
RETURN_DOM_FRAGMENT: true,
ADD_ATTR: ['target', 'rel']
});
var holder = document.createElement('div');
holder.className = 'atc-markdown';
holder.appendChild(fragment);
resolveRelativeUrls(holder, data.url); // 相对路径按 md 所在目录解析
v.body.textContent = '';
v.body.appendChild(holder);
}).catch(function (err) { if (alive()) failState(v, data, err); });
}

resolveRelativeUrls 做的事是:以这份 Markdown 的 URL 为 base,把 img[src]a[href] 里的相对路径转成绝对路径——附件写的是 ../img/meng.png,站点上要指向 /img/meng.png;页内锚点则拦下来在浮层内部滚动,免得把宿主页面的地址栏搞乱。

纯文本和源码分支更省事,textContent 写进 <pre>,天然免疫注入。另外 fetchText 里卡了个 2 MB 上限,超了就只提示下载,不然一份大日志能把页面拖死。

剩下的都是浮层该有的礼貌:Esc 关闭、Tab 焦点陷阱、打开时给 <html>.atc-locked 锁滚动、关闭后把焦点还给原来那张卡片、监听 pjax:send 收起浮层。

六、样式

source/css/attachment.css,两条原则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 卡片样式全部限定在 .post-body 内,避免污染其它组件 */
.post-body .atc-card,
.atc-viewer {
--atc-accent: #495a80; /* 默认沿用 NexT 链接色 */
--atc-surface: rgba(255, 255, 255, 0.62);
--atc-border: rgba(90, 100, 125, 0.18);
--atc-radius: 5px;
}

/* 文件类型强调色,改一行就换一种色 */
.post-body .atc-tone-pdf { --atc-accent: #c75146; }
.post-body .atc-tone-sheet { --atc-accent: #2f8a5b; }
.post-body .atc-tone-slide { --atc-accent: #d2803a; }
.post-body .atc-tone-code { --atc-accent: #7a5ea8; }
.post-body .atc-tone-image { --atc-accent: #2f8a9e; }

一是所有卡片选择器都带 .post-body 前缀,别让它漏到侧栏和其它组件上;二是强调色走 CSS 变量,tone 由插件写在 class 里,加新类型只需要补一行变量。预览器挂在 body 下,所以单独用 .atc-viewer 命名空间。

七、注入到页面

NexT 用 source/_data/ 注入就行(要先在主题配置里打开 custom_file_path)。

head.swig 里放样式,避免首屏闪一下:

1
2
{# 附件卡片与附件预览器样式:放在 head 中以避免首屏样式闪烁 #}
<link rel="stylesheet" href="{{ url_for('/css/attachment.css') }}" />

body-end.swig 里放脚本,顺手把 root 和两个库的地址传给前端,省得脚本自己拼路径(博客挂子目录时尤其重要):

1
2
3
4
5
6
7
8
9
{# 附件卡片与沉浸式附件预览器 #}
<script>
window.ATTACHMENT_CONFIG = {
root: "{{ config.root }}",
markedUrl: "{{ url_for('/lib/attachment/marked.js') }}",
purifyUrl: "{{ url_for('/lib/attachment/purify.min.js') }}"
};
</script>
<script src="{{ url_for('/js/attachment.js') }}" defer></script>

marked 和 DOMPurify 是用到才加载的:只有点开 Markdown 附件时才会 loadScript 拉进来,加载过一次就缓存住。没人点附件的页面,一个字节都不多花。

八、怎么用

把文件丢进 source/files/,然后在文章里写:

1
{% attachment "/files/story-bible.md" "桃花源创作大纲详细版" "md" "28 KB" %}

四个参数按位置来,只有第一个是必填的:

参数 说明
path 附件路径,/ 开头按站点根解析,也支持完整外链
title 卡片标题,省略时取文件名
ext 扩展名,省略时从路径推断
size 大小文本,如 28 KB,省略则副标题只显示类型

最后 hexo clean && hexo g 走一遍。

九、几个坑

  1. skip_render 一定要加。忘了这条,Markdown 附件会被渲染成 HTML 页面,前端 fetch 回来一堆标签,预览器里全是乱码一样的东西。
  2. 别信任 Markdown 附件的内容。附件是纯文本 fetch 进来的,里面可以塞 <script>onerror,不过 DOMPurify 那道必须存在。我专门在测试文档里放了几个 XSS 探针来验证清理链路。
  3. 卡片标题别用标题标签,理由见上文,NexT 的 TOC 会把它吃进去。
  4. content-length 不一定有。开了 gzip 或分块传输时拿不到长度,所以体积上限只能算尽力而为,兜底还是靠 pre 渲染本身足够快。
  5. PJAX 要收浮层。不监听 pjax:send 的话,换页之后遮罩会挂在那儿,整个站点像死机了一样。

统共三个文件加两处注入,不动主题源码,以后升级 NexT 也不会被覆盖掉。附件多的博客值得一做。