百合博客翻新记(1/2) 自定义Hexo-Landscape主题

本篇记录我把Hexo博客主题更换为Landscape并自定义的过程。

更换主题

要选择一个充分发挥博客作用的主题,必须要显示足够多的内容(标签、分类、归档等),布局要清晰有条理,并符合读者浏览的惯性和预期。

以这个标准来看,Hexo的默认主题Landscape就非常好。同时也非常简洁,有利于把读者的注意力集中到真正有意义的内容上。

由于是默认主题,Hexo内置依赖项已经包含Landscape。但为了方便自定义,我把Landscape主题clone/themes目录下。

clone进来后,新主题会变成本地库的内嵌子库,远程主题库可以跟踪其变化。
为了方便后续修改,我删除了/themes/landscape目录下的.git及相关文件,并清除本地git缓存,让本地库统一管理博客目录下的所有文件。

关于新主题的初步设置,我参考了并推荐这个tutorial系列

自定义主题

Landscape算老主题了,写这篇博文的当下,该主题的/source目录最近更新已是2年前。
我在用目前的最新版v1.1.0,但有些老代码需要修改,又或者根据需要添加新功能。

显示文章更新日期

文章更新日期是很重要的信息,而Lanscape只显示发布日期。要显示更新日期,需要动2个文件:

  1. 修改themes/landscape/layout/_partial/article.ejs

    参照已有的发布日期代码:

    1
    <%- partial('post/date', {class_name: 'article-date' , date_format: null}) %>

    我们可以写一个更新日期的代码,并放在你想要显示的位置:

    1
    <%- partial('post/update-date', {class_name: 'article-date' , date_format: null}) %>

    post/update-date指的是引用当前文件article.ejs所在的/_partial目录下的post/update-date.ejs文件。

  2. 添加themes/landscape/layout/_partial/post/update-date.ejs

    同样地,参照themes/landscape/layout/_partial/post/date.ejs

    1
    2
    3
    4
    5
    <span class="<%= class_name %>">
    <time class="dt-published" datetime="<%= date_xml(post.date) %>" itemprop="datePublished">
    <%= date(post.date, date_format) %>
    </time>
    </span>

    我们把以下代码写入update-date.ejs文件:

    1
    2
    3
    4
    5
    6
    7
    <% if (post.published && post.updated && post.updated.isAfter(post.date, 'day')) { %>
    <span class="<%= class_name %>">
    <time class="dt-updated" datetime="<%= date_xml(post.updated) %>" itemprop="dateModified">
    更新于 <%= date(post.updated, date_format) %>
    </time>
    </span>
    <% } %>

    表示已发布文章的updated有值、并且以日期粒度比date晚时,才显示更新日期。

    一般来说,post.updated的值就算没写在文章的front-matter里也可以自动拿到。
    但这样一来,就算你只是加个空格,它都会判断你更新了。所以建议自己手动在front-matter设置updated

    1
    2
    3
    4
    5
    ---
    title: "abc"
    date: 2022-04-26
    updated: 2022-04-26
    ---

修改新旧文章导航链接顺序

按照一贯的逻辑,文章底部的新旧文章导航链接的显示应该类似:
← 旧帖 新帖 →

但是你可以在Landscape的官方demo里看到,这个顺序是反过来的:
← newer older →

当我发现这个bug时,be like这是在…?bignose

它不是error报错,但我惊讶于它被设计得如此不符合用户习惯,以至于我可以称之为bug sideeye

我猜是为了在小屏显示时,让older自动跑到newer下面。
这样确实比较合理,但我们大多是用电脑看博客,所以有点因小失大了。

而在themes/landscape/layout/_partial/post/nav.ejs文件的源码中,post.next的值是旧帖,post.prev的值是新帖。
这怎么不算震撼人心呢 myeyes

所以要改正这个顺序,就要修改2组数据:prev/next和older/newer。

  1. prev/next控制文章标题的顺序

    为了不在改代码时变得左右不分,我先定义了可读性更高的变量 hehe

    1
    2
    3
    4
    <%
    const previousPost = post.next; // 旧
    const nextPost = post.prev; // 新
    %>

    之后就是置换上述nav.ejs文件里对应的值:
    post.prevpreviousPost
    post.nextnextPost

  2. older/newer控制文章标题上方说明文字的顺序和内容

    为了提高代码可读性,先把上述nav.ejs文件中的”older”和”newer”互换。

    改文字顺序:
    来到themes/landscape/source/css/_partial/article.styl文件,同样把”older”和”newer”互换。

    改文字内容:
    themes/landscape/languages/你的语言.yml里修改newer和older的值,我分别改成了Newer和Older。

这样就改好宽屏下的导航链接顺序了。

但是这样一来,到了窄屏就会变成older在上newer在下,而用户习惯看到新文章在上。

这时只需要在上述article.styl文件中添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#article-nav
// ...其它code
display: flex
flex-direction: column
@media mq-normal
display: block
// ...其它code

#article-nav-older
order: 2
@media mq-normal
// ...其它code

#article-nav-newer
order: 1
@media mq-normal
// ...其它code

这表示#article-nav在窄屏时变成flex容器,并利用子项的order属性来调整older/newer的位置顺序。

添加Waline评论系统

Landscape支持ValineDisqus评论系统。但Valine已经不能注册了,而Disqus则有广告。

我希望博客评论区支持匿名评论,并且干净简洁无广告,所以我决定导入Waline评论系统。
这是主题自定义里比较花时间的一环,但是我的博客值得 tongue-2

首先按照Waline官方指南,把评论系统部署到Vercel上。
接着把部署好的评论系统集成到Landscape主题,这需要改动4个文件:

  1. themes/landscape/layout/_partial/article.ejs添加留言计数器和评论区域:

    1
    2
    3
    4
    5
    6
    7
    // 在footer元素内添加留言计数器
    <% if (post.comments && theme.waline.enable && theme.waline.serverURL) { %>
    <a href="<%- url_for(post.path) %>#waline" class="article-comment-link">
    <span class="post-comments-count waline-comment-count fa fa-comment" data-path="<%= url_for(post.path) %>" itemprop="commentCount"></span>
    <%= __('comment') %>
    </a>
    <% } %>
    1
    2
    3
    4
    5
    6
    // 添加评论区域
    <% if (!index && post.comments && theme.waline.enable && theme.waline.serverURL){ %>
    <section id="comments">
    <div id="waline"></div>
    </section>
    <% } %>
  2. themes/landscape/layout/_partial/head.ejshead元素内引入Waline的css

    1
    2
    3
    <% if (theme.waline.enable && theme.waline.serverURL){ %>
    <%- css('https://unpkg.com/@waline/client@3.13.0/dist/waline.css') %>
    <% } %>
  3. themes/landscape/_config.yml中配置参数:

    1
    2
    3
    4
    # Waline comment system
    waline:
    enable: true
    serverURL: https://example.domain.com
  4. themes/landscape/layout/_partial/after-footer.ejs初始化评论区和计数器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <% if (theme.waline.enable && theme.waline.serverURL){ %>
    <script type="module">
    import { init, commentCount } from 'https://unpkg.com/@waline/client@v3/dist/waline.js';

    // 等DOM完全加载后再执行,确保所有留言计数器都存在页面中
    document.addEventListener('DOMContentLoaded', function () {
    // 文章页(含#waline元素):init + commentCount
    if (document.getElementById('waline')) {
    // 初始化评论区
    init({
    el: '#waline',
    serverURL: theme.waline.serverURL,
    });
    }
    // 主页:commentCount
    // 初始化计数器
    commentCount({
    serverURL: theme.waline.serverURL,
    selector: '.waline-comment-count'
    });
    });
    </script>
    <% } %>

这样就算集成好了。

但我还想修改评论区的样式,因为Waline评论区的主题色是绿色,而博客的主题色是蓝色。

为了方便博客样式的统一管理,我把Waline的CSS文件下载到themes/landscape/source/external/目录下。
在线CSS Beautifier格式化css文件,改好样式后再用VScode的Minify插件压缩,最后更新上述head.ejs文件的CSS路径即可。

导入站内表情包

仅用文字就能表达微妙情绪是作家的本事。而我文笔拙劣,没有表情包简直寸步难行 cry-cat

这里说的站内表情包是写博客时用的,如果想在Waline评论区设置自定义表情包,参考这个官方文档

导入站内表情包后,可直接用:表情包名:的格式输出到博文。要达到这个目标,需要走5步:

  1. 添加表情包图片文件

    这里假设添加到source/images/stickers目录。

    之所以用本地表情包,是因为网络图片有可能被撤走,也不好浪费人家的带宽。
    但可以把表情包目录部署到其它可访问的地址,这里不作讨论。

  2. 注册hexo filter,让:表情包名字:自动转换为该表情包的img元素

    新建themes/landscape/scripts/sticker.js文件,参考以下代码:

    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
    const fs = require('fs');
    const path = require('path');

    // 支持的文件格式
    const exts = ['gif', 'png', 'jpg', 'webp'];
    let stickerMap = null;

    function loadStickers(hexo) {
    if (stickerMap) return stickerMap;

    // 表情包目录
    const dir = path.join(hexo.source_dir, 'images/stickers');

    stickerMap = new Map();
    if (!fs.existsSync(dir)) return stickerMap;

    // 读取 dir 文件夹里的所有文件名,返回数组
    const files = fs.readdirSync(dir);

    for (const file of files) {
    const ext = path.extname(file).slice(1).toLowerCase(); // 获取文件后缀
    const name = path.basename(file, path.extname(file)); // 去掉后缀的文件名

    // 判断文件格式是否支持,是则塞入map
    if (exts.includes(ext)) {
    stickerMap.set(name, file);
    }
    }

    return stickerMap;
    }

    hexo.extend.filter.register('before_post_render', function (data) {
    const stickers = loadStickers(hexo);

    // match = ":cat:" 整个匹配到的字符串
    // name = "cat" 正则表达式括号里的内容
    data.content = data.content.replace(/:([a-zA-Z0-9_-]+):/g, (match, name) => {
    const file = stickers.get(name); // 获取 文件名.后缀

    if (!file) return match; // 找不到则显示原文字内容

    return `<img src="/images/stickers/${file}" class="sticker" alt="${name}" loading="lazy">`;
    });

    return data;
    });

    这段代码先扫描表情包目录,找到符合指定格式的文件,生成一个以文件名: 文件名.后缀为键值对的map,然后在文章中找:表情包名:并在map里找到它的后缀,返回相应的img元素。

  3. 设置表情包样式

    这时已经可以用:表情包名:的格式输出表情包了,但需要调整一下样式。

    新建themes/landscape/source/css/_partial/sticker.styl(注意上一步返回的img元素有.sticker类):

    1
    2
    3
    img.sticker
    display: inline-block // 和文字同一行
    vertical-align: text-bottom // 垂直方向上的对齐位置

    要想确保表情包不会过大,也可以添加max-height等属性。

    themes/landscape/source/css/style.styl导入新样式,让其生效:

    1
    @import "_partial/sticker"
  4. 防止表情包进入fancybox画廊

    Landscape会扫描文章里的img元素,并用一个a元素将其包裹,让它可以被点击进入fancybox画廊里浏览。但表情包不需要这个功能(到底是要放进去欣赏什么 sideeye

    themes/landscape/source/js/script.js进行以下修改:

    1
    2
    3
    4
    5
    6
    7
    // 修改前
    $('.article-entry').each(function(i){
    $(this).find('img').each(function(){

    // 修改后
    $('.article-entry').each(function(i){
    $(this).find('img').not('.sticker').each(function(){

    这样就能避免fancybox处理带.sticker类的img元素。

  5. 更新source/robots.txt,禁止搜索引擎抓取表情包

    表情包只是起修饰作用,不需要展示在搜索结果里。在robots.txt文件中添加:

    1
    Disallow: /images/stickers/

注册带色文字hexo tag

总有些时候需要用不同颜色来突出一段文字,或是赋予它特殊含义。

比起每次都手写内联css样式的html,我选择注册hexo tag,以便使用 {% tagname 颜色 文字内容 %} 的格式输出带色文字。

新建themes/landscape/scripts/colortxt.js文件,注册colortxt标签,参考以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
hexo.extend.tag.register('colortxt', function(args){
// css基本颜色参数
const colors = ['red', 'blue', 'green', 'yellow', 'orange', 'black', 'purple', 'pink', 'brown', 'gray'];

let color = 'red'; // 默认红色
let text = '';

if (args.length === 0) return '';

// 判断第一个参数是否为css基本颜色
if (colors.includes(args[0].toLowerCase())) {
// 是,设置为该颜色
color = args[0].toLowerCase();
text = args.slice(1).join(' ');
} else {
// 否,默认红字
text = args.join(' ');
}

return `<span style="color:${color}">${text}</span>`;
});

这段代码支持使用{% colortxt 颜色 文字内容 %}{% colortxt 文字内容 %}输出带色文字,没指定颜色则默认红色。
可用的颜色参数有:red, blue, green, yellow, orange, black, purple, pink, brown, gray

要注意,假设你想输出默认红字的blue sky,若写成{% colortxt blue sky %},实际会输出sky
因为blue是colors数组里的颜色参数,它以为你想让”sky”变蓝。

所以当文字内容的开头是颜色参数,但实际想输出默认红字时,需要用英文引号"包起文字内容"
{% colortxt "blue sky" %}blue sky

行内代码自动换行

Landscape居然行内代码不会自动换行?!!myeyes
这还是我在写这篇博文时发现的bug!!

谁懂我看到行内代码在窄屏下像剑一样笔直刺穿border时的感受 facepalm-1

你贵为一个知名开源项目的默认主题
你居然…居然…你… huffy biggrin smile

回归正题,在themes/landscape/source/css/_partial/highlight.styl文件里加一行代码即可:

1
2
3
4
5
.article-entry
// ...其它code
code
// ...其它code
word-wrap: break-word

写作工具

如果你一路跟下来并完成上面的tutorial,恭喜你!你做到了!!我也做到了!!!clap

至于工具,就在于让写博客变得简单方便。

我开始用Typora,这篇博文就是我用它来写的。
比起自己从头到尾吭哧吭哧写markdown,直接设置格式的感觉很不错 titter

参考

Hexo定制化-更新时间与发布时间

[學習筆記] VS Code的Minify壓縮器