这篇文章主体是Opus 5写的,有时候真的感慨ai的方便,以前想添加这种小功能,又不会编程,就是跑到别人博客到处抄。
现在cc一装,账户一连,提出要求,看会小说回来就发现已经搞好了,甚至还写了个教程。
Man,what can I say
总之这确实是一个很重要的需求,因为我老是喜欢赛博斗蛐蛐,尤其是之前A/出bug零元购max20x账户让我爽玩了一晚上Fable5,不仅写了几个网页还生产了一堆清水文,让我好不过瘾。但是想要放到博客上就很累了,复制粘贴固然简单但是排版特别烂。
所以就有了添加这个功能的契机,那么下面就是教程了,由Opus 5撰写。
写文章的时候经常想丢一份文件给读者——一份大纲、一张表、一段源码。以前我的做法是甩一个裸链接出去,读者点一下,浏览器要么下载,要么把 Markdown 当纯文本糊在屏幕上,丑得很。
于是花了个下午做了这么个东西:文章里写一行标签,编译出来是一张附件卡片;点一下卡片,页面上浮起一层预览器,Markdown 当场渲染,PDF 内嵌,图片直接看,办公文档提示下载。全程不跳页,不依赖任何在线预览服务。
下面是成品,可以点开试试:
MD 附件预览器测试文档 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/attachmentcp node_modules/marked/lib/marked.umd.js source /lib/attachment/marked.jscp 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 'use strict' ;const urlFor = require ('hexo-util' ).url_for ;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 压缩包' } }; const FALLBACK_TYPE = { kind : 'binary' , tone : 'default' , label : '文件' };function escapeHtml (s ) { return String (s == null ? '' : s) .replace (/&/g , '&' ).replace (/</g , '<' ).replace (/>/g , '>' ) .replace (/"/g , '"' ).replace (/'/g , ''' ); } 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 ); 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 .atc-card ,.atc-viewer { --atc-accent : #495a80 ; --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 走一遍。
九、几个坑
skip_render 一定要加 。忘了这条,Markdown 附件会被渲染成 HTML 页面,前端 fetch 回来一堆标签,预览器里全是乱码一样的东西。
别信任 Markdown 附件的内容 。附件是纯文本 fetch 进来的,里面可以塞 <script> 和 onerror,不过 DOMPurify 那道必须存在。我专门在测试文档里放了几个 XSS 探针来验证清理链路。
卡片标题别用标题标签 ,理由见上文,NexT 的 TOC 会把它吃进去。
content-length 不一定有 。开了 gzip 或分块传输时拿不到长度,所以体积上限只能算尽力而为,兜底还是靠 pre 渲染本身足够快。
PJAX 要收浮层 。不监听 pjax:send 的话,换页之后遮罩会挂在那儿,整个站点像死机了一样。
统共三个文件加两处注入,不动主题源码,以后升级 NexT 也不会被覆盖掉。附件多的博客值得一做。