Hexo-NexT主题安装并配置教程

前言: 该教程不定时更新 ,该教程主要提供 Hexo 和 NexT 官方教程上没有内容,至于其他的请阅读官方教程!

Hexo:快速,简单且功能强大的博客框架

  • 由Node.js提供支持的令人难以置信的生成速度。数以百计的文件仅需几秒钟即可构建。
  • 支持GitHub Flavored Markdown的所有功能,包括大多数Octopress插件。
  • 您只需要一个命令即可将您的站点部署到GitHub Pages,Heroku或其他平台。
  • 具有功能强大的API,可实现无限扩展。各种插件可用于支持大多数模板引擎(EJS,Pug,Nunjucks等)。轻松与现有NPM软件包(Babel,PostCSS,Less / Sass等)集成。

NexT:Hexo的典雅而强大的主题。(该GitHub仓库上的NexT版本为稳定版)

  • NexT 支持大量的第三方插件,它们可以被轻松地配置。
  • NexT 每个月都会发布新版本。
  • 最新的GitHub地址(该GitHub仓库上的NexT版本为开发版)。
  • NexT 官方配置教程及更新说明网址。

基本安装

  1. 安装Hexo

    • 安装Node.js(Node.js 版本需不低于 10.13,建议使用 Node.js 12.0 及以上版本)
    • 安装Git
    • 安装 Hexo(这里将Hexo作为博客目录)
      1
      npm install -g hexo-cli
      1
      hexo init Hexo
      1
      cd Hexo
      1
      npm install
    • Hexo安装完成
  2. 安装NexT

    • 安装NexT
      1
      cd Hexo/
      1
      npm install hexo-theme-next

      NexT安装完成

    • 使用NexT主题
      1
      theme: next

      修改Hexo\_config.yml配置

Hexo插件

  1. hexo-enhancer

    • 简介

      • hexo-enhancer 是一个Hexo功能增强插件。
      • 此插件支持的功能较多,到目前为止,此插件支持的功能如下:
        • 自动生成 title:根据文件名自动生成标题。
        • 自动生成 date:根据文件名自动生成日期,具体策略类似 Jekyll。
        • 自动生成 abbrlink:根据标题进行 base32 和 crc32 生成短链接。
        • 自动生成 categories:根据文件的路径解析文章所属分类。
        • 自动生成 tags:根据配置在 _config.yml 中的可用 tags 和 keywords 自动扫描文章中出现的标签。
    • 安装

      1
      npm install hexo-enhancer
    • 基础配置

      • 将 Hexo\scaffolds\post.md 做如下修改

        1
        2
        title: {{ title }}
        date: {{ date }}

        即删除了原来的 tags:

      • Hexo\_config.yml 配置标签设置:

        1
        tags: Java, Golang, React, Vue, ...

        自动给每篇文章添加标签,将你要的标签填写到 tags 后边即可

      • Hexo\_config.yml 配置 abbrlink 设置

        1
        permalink: :year/:abbrlink/

        自动为每篇文章分配短小且唯一的 url

  2. RSS

  3. live2d

    • 一个往Hexo博客里放上一只萌萌哒二次元看板娘的插件
    • 使用教程
  4. hexo-submit-urls-to-search-engine

    • 简介

      • 中文文档
      • hexo使用 hexo-submit-urls-to-search-engine 插件后,可主动推送Hexo博客新链接至谷歌、必应、百度搜索引擎站长平台以提升网站收录质量和速度。本插件允许你向各大搜索引擎发送纯天然、植物饲养的索引编制请求,一劳永逸。
      • 比如,现在我的一篇文章刚发布 5 分钟,即可在必应搜索到。看吧,这就是 hexo-submit-urls-to-search-engine 的威力。百度与谷歌略慢一筹。
      • 这三大搜索引擎已占据97%的全球搜索引擎市场份额(包括使用bing索引的雅虎、ecosia等)。后期可能会加入向Yandex(官方仅开通重点页面提交)、360(目前官方没开通,但我已提交建议)提交链接的功能。
    • 安装

      1
      npm install --save hexo-submit-urls-to-search-engine
    • Hexo配置

      _config.yml
      1
      2
      3
      4
      5
      6
      # Deployment
      ## Docs: https://hexo.io/docs/deployment.html
      deploy:
      - type: cjh_google_url_submitter
      - type: cjh_bing_url_submitter
      - type: cjh_baidu_url_submitter

      在deploy下添加以上内容

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      hexo_submit_urls_to_search_engine:
      submit_condition: count #链接被提交的条件,可选值:count | period 现仅支持count
      count: 10 # 提交最新的10个链接
      period: 900 # 提交修改时间在 900 秒内的链接
      google: 0 # 是否向Google提交,可选值:1 | 0(0:否;1:是)
      bing: 1 # 是否向bing提交,可选值:1 | 0(0:否;1:是)
      baidu: 1 # 是否向baidu提交,可选值:1 | 0(0:否;1:是)
      txt_path: submit_urls.txt ## 文本文档名, 需要推送的链接会保存在此文本文档里
      baidu_host: https://cjh0613.github.io ## 在百度站长平台中注册的域名
      baidu_token: 请按照文档说明获取 ## 请注意这是您的秘钥, 所以请不要把它直接发布在公众仓库里!
      bing_host: https://cjh0613.github.io ## 在bing站长平台中注册的域名
      bing_token: 请按照文档说明获取 ## 请注意这是您的秘钥, 所以请不要把它直接发布在公众仓库里!
      google_host: https://cjh0613.github.io ## 在google站长平台中注册的域名
      google_key_file: Project.json #存放google key的json文件,放于网站根目录(与hexo _config.yml文件位置相同),请不要把json文件内容直接发布在公众仓库里!
      replace: 0 # 是否替换链接中的部分字符串,可选值:1 | 0(0:否;1:是)

      在Hexo末尾添加以上内容

    • 成功推送返回信息

      • 必应

        1
        Bing response:  { d: null }
      • 百度

        1
        Baidu response:  {"remain":2999,"success":1}
      • 谷歌

        1
        2
        3
        4
        5
        6
        7
        8
        Google response:  { urlNotificationMetadata:
        { url:
        'https://cjh0613.github.io',
        latestUpdate:
        { url:
        'https://cjh0613.github.io',
        type: 'URL_UPDATED',
        notifyTime: '2020-06-12T05:37:25.701779228Z' } } }
      • 其他返回信息可参考它们的官方文档。

  5. hexo-blog-encrypt

    • 简介

      为单篇 Hexo 文章设置密码。构建时使用 AES-256-GCM 加密,读者在浏览器中通过 Web Crypto API 解密。无需服务端,生成的 HTML 中不含明文。

    • 安装

      1
      npm install --save hexo-blog-encrypt
    • Hexo 配置

      _config.yml
      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
      encrypt: # hexo-blog-encrypt
      abstract: 有东西被加密了, 请输入密码查看.
      message: 您好, 这里需要密码.
      wrong_pass_message: 抱歉, 这个密码看着不太对, 请再试试.
      theme: default
      silent: false

      # 加密所有带有以下标签的文章.
      tags:
      - {name: 作为日记加密, password: 密码A}
      - {name: 作为提示加密, password: 密码B}

      # 在密码框旁显示 "解密" 按钮. 默认: true.
      decryptButton:
      show: true
      text: 解密

      # 将派生出的 AES 密钥缓存到 localStorage, 刷新页面时跳过密码输入.
      # 默认: false. 可在这里全局开启, 也可在文章信息头中单独开启.
      # 缓存键为 `hbe.v4.<页面路径>`.
      autoSave: false

      # 让同一篇文章的 PBKDF2 salt 在 clean build 之间保持稳定, 文章身份来自
      # post permalink. 默认: false. 配合 autoSave 时, 适用于
      # Cloudflare Pages、Vercel、Netlify、GitHub Actions 等每次部署都会从
      # 干净环境重新生成 HTML 的平台.
      #
      # 只稳定 salt; AES-GCM nonce 每次加密仍然随机, 避免 nonce reuse.
      # 如果文章 permalink 改变, stable salt 也会改变, 该页面已有的
      # localStorage 缓存会失效.
      stableSalt: false

      # PBKDF2 迭代次数. 下限: 100_000. 推荐 ≥ 600_000
      # (OWASP 2023 关于 PBKDF2-SHA256 的推荐). 低于推荐值时构建会打印 warning.
      # 默认: 250_000.
      kdf:
      iterations: 250000
    • 使用

      在文章信息头中加入 password:

      1
      2
      3
      4
      5
      ---
      title: Hello World
      date: 2016-03-30 21:18:02
      password: hello
      ---
    • 与 NexT 侧栏目录(TOC)冲突

      • 现象:加密文章打开后侧栏没有「文章目录」,输入密码解密之后也没有。
      • 原因:NexT 的侧栏目录是构建期生成的,layout/_macro/sidebar.njk 里对 page.content 调用 toc(),再以 toc.length > 1 决定要不要渲染目录面板;而 hexo-blog-encrypt 在 after_post_render(优先级 1000)阶段把正文整个换成密码框外壳,模板拿到的 page.content 里已经没有任何 <h2>/<h3>,目录面板就直接不渲染了——不是解密脚本没跑,而是 HTML 里压根没生成。
      • 插件在替换前把加密前的正文留在了 data.origin 上,官方 issue #58 的常见解法就是让模板改用 toc(page.origin)。但那样标题会明文出现在 HTML 源码里,密码还没输就能看到整篇文章的结构。所以这里改成「构建期把目录算好、连同正文一起加密,解密成功后再搬回侧栏」,主题和插件(都在 node_modules 里)一行都不用改。
    • 修复实现

      第一步:站点根目录新建 scripts/hexo-blog-encrypt-toc.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
      /* global hexo */

      'use strict';

      // hexo-blog-encrypt 在 after_post_render(优先级 1000)里把正文换成密码框外壳,
      // NexT 的侧栏目录又是对 page.content 生成的,所以加密文章没有目录。
      // 这里赶在加密之前把目录算好、连同正文一起加密进去,解密后由 source/_data/body-end.njk 搬回侧栏,
      // 这样密码输对之前目录标题既不会显示、也不会留在 HTML 源码里。
      const toc = hexo.extend.helper.get('toc');

      const tagNames = (hexo.config.encrypt && hexo.config.encrypt.tags || []).map(item => item.name);

      const isEncrypted = data => typeof data.password === 'string' && data.password.length > 0
      || tagNames.some(name => (data.tags || []).some(tag => tag.name === name));

      hexo.extend.filter.register('after_post_render', data => {
      if (!isEncrypted(data)) return;

      const { number: listNumber = true, max_depth: maxDepth = 6 } = hexo.theme.config.toc || {};
      const html = toc.call(null, data.content, {
      class: 'nav',
      list_number: listNumber,
      max_depth: maxDepth
      });

      if (html) {
      data.content = `<div class="hbe-toc" hidden>${html}</div>${data.content}`;
      }
      }, 999);

      第二步:在 source/_data/body-end.njk 里把解密出来的目录搬回侧栏(该文件需已在 _config.next.yml 的 custom_file_path.bodyEnd 中启用)。文件里加下面这段,注意监听要挂在 window 上——插件是用 window.dispatchEvent 派发 hexo-blog-decrypt 的。

      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
      {# 加密文章:解密成功后,把随正文一起加密的目录搬回侧栏(配合 scripts/hexo-blog-encrypt-toc.js) #}
      <script>
      (() => {
      const wrap = () => document.querySelector('.post-toc-wrap');

      const renderToc = () => {
      const source = document.querySelector('.hbe-toc');
      const container = wrap();
      if (!source || !container) return;

      const panel = document.createElement('div');
      panel.className = 'post-toc animated';
      panel.appendChild(source.firstElementChild);
      // replaceChildren 而不是 appendChild:pjax 切过来时侧栏里可能还留着上一篇的目录
      container.replaceChildren(panel);
      source.remove();

      document.querySelector('.sidebar-inner').classList.add('sidebar-nav-active');
      NexT.utils.registerSidebarTOC();
      NexT.utils.activateSidebarPanel(0);
      NexT.utils.updateSidebarPosition();
      };

      // 整页加载或 pjax 进入加密文章时,NexT 只会给上一篇的目录打上 placeholder-toc 而不删除,先清掉
      document.addEventListener('pjax:success', () => {
      const locked = document.querySelector('#hexo-blog-encrypt:not(.hbe-decrypted-content)');
      if (locked) wrap()?.replaceChildren();
      });

      window.addEventListener('hexo-blog-decrypt', renderToc);
      })();
      </script>
    • 注意事项

      • scripts/ 下的脚本只在 Hexo 启动时加载一次,而且文章渲染结果会缓存进 db.json。改完脚本必须 hexo clean 之后再 hexo server / hexo generate,否则就是"改了没生效"。
      • 脚本同时识别文章 front-matter 的 password 和 _config.yml 里 encrypt.tags 配置的标签密码;目录层级与编号跟随 _config.next.yml 的 toc.number / toc.max_depth。
      • 开启 pjax 时 pjax:success 那段必须保留:NexT 的 PJAX 在切到"没有目录的文章"时,只给上一篇的目录打 placeholder-toc 标记而不删除,侧栏会残留上一篇文章的标题。
      • 解密后目录的点击滚动、当前章节高亮、子项展开与未加密文章完全一致,因为复用的是主题自己的 NexT.utils.registerSidebarTOC() / activateSidebarPanel(),没有另写一套。
      • 实测版本:hexo-blog-encrypt 4.1.0 + NexT 8.29.0(pjax 开与关都验证过:解密前 DOM 里查不到任何标题,解密后目录条目数与文章标题数一致)。
  6. hexo-filter-date-from-git

    • 简介

      • 读取 Git 提交日志,自动覆盖文章 front-matter 的 date(创建时间)和 updated(最后更新时间)
      • 适合用 Git 完整托管博客源码的人;换电脑、clone 仓库后,不会因为文件 mtime(系统修改时间)错乱导致更新日期乱跳
      • 不用每次改文章手动维护 updated,提交 Git 之后,下次 hexo g 自动刷新更新时间
      • 想要手动精确控制:保留插件,在需要固定更新时间的文章 front-matter 里显式写 updated(插件遇到有效字段就跳过)
    • 安装

      1
      npm install hexo-filter-date-from-git --save

      安装后,在使用的时候建议先将修改的文章提交到 Git,然后在执行 hexo g

  1. hexo-word-counter

    • 简介

      • Hexo 博客文章的字数和阅读时间。
      • 字数统计以Unicode® 标准附件 #29为依据,因此当帖子内容包含多种语言时,总字数也能准确统计。
      • 借助 Rust 的强大功能,此插件比几乎所有提供类似功能的其他 Hexo 插件都更快。请参阅下面的基准测试。
    • 安装

      1
      npm install hexo-word-counter
    • Hexo 配置

      _config.yml
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      # 发布字数统计
      # https://github.com/next-theme/hexo-word-counter
      symbols_count_time:
      symbols: true
      time: true
      total_symbols: true
      total_time: true
      exclude_codeblock: false
      wpm: 300
      suffix: "mins."
    • Next 配置

      _config.yml
      1
      2
      3
      4
      5
      # Post wordcount display settings
      # Dependencies: https://github.com/next-theme/hexo-word-counter
      symbols_count_time:
      separated_meta: true
      item_text_total: false
  2. hexo-generator-searchdb

    • 简介

      • Hexo 的搜索数据生成器插件。
      • 该插件用于生成搜索索引文件,其中包含文章的所有必要数据,您可以使用这些数据为博客编写本地搜索引擎。支持XML和JSON格式输出。
    • 安装

      1
      npm install hexo-generator-searchdb
    • Hexo配置

      _config.yml
      1
      2
      3
      4
      5
      6
      7
      # 本地搜索
      # hhttps://github.com/next-theme/hexo-generator-searchdb
      search:
      path: search.xml
      field: post
      content: true
      format: html
    • NexT配置

      _config.yml
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      # Local Search
      # Dependencies: https://github.com/next-theme/hexo-generator-searchdb
      local_search:
      enable: true
      # If auto, trigger search by changing input.
      # If manual, trigger search by pressing enter key or search button.
      trigger: auto
      # Show top n results per article, show all results by setting to -1
      top_n_per_article: 1
      # Unescape html strings to the readable one.
      unescape: false
      # Preload the search data when the page loads.
      preload: true
  3. hexo-filter-emoji

    • 简介

    • 安装

      1
      npm install hexo-filter-emoji
    • Hexo 配置

      _config.yml
      1
      2
      3
      4
      5
      6
      7
      8
      9
      # 一个使用Github Emojis API添加了表情符号支持的Hexo插件
      # https://github.com/theme-next/hexo-filter-emoji
      # https://api.github.com/emojis
      # https://www.webfx.com/tools/emoji-cheat-sheet/
      emoji:
      enable: true
      className: github-emoji
      styles:
      customEmojis:
  4. hexo-next-twikoo

    • 简介

      • NexT 的 Twikoo 评论系统。
      • Twikoo 是一个简单、安全、免费的评论系统。
    • 安装

      1
      2
      3
      4
      # For NexT version >= 8.0.0 && < 8.4.0
      npm install hexo-next-twikoo@1.0.0
      # For NexT version >= 8.4.0
      npm install hexo-next-twikoo@1.0.3
    • 配置

      在 Hexo 或主题的 _config.yml 文件中配置它们:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      # Twikoo
      # For more information: https://twikoo.js.org, https://github.com/imaegoo/twikoo
      twikoo:
      enable: true
      visitor: true
      envId: twikoo-1gs9l0fb17e7897a # 环境 ID,搭建教程:https://twikoo.js.org/quick-start.html
      # region: ap-guangzhou # 环境地域,默认为 ap-shanghai
      # path: 'window.location.pathname' # 自定义文章路径
      # jsUrl: https://cdn.staticfile.org/twikoo/1.5.10/twikoo.all.min.js # 自定义 JS 文件地址
    • 评论系统推荐

  5. hexo-next-fireworks

    • 简介一个鼠标点击动画特效!
    • 安装
      1
      npm install next-theme/hexo-next-fireworks
    • 用法您可以在 Hexo 的 _config.yml (位于您博客的根目录中)中设置此插件的选项:
      1
      2
      fireworks:
      enable: true
  6. activate-power-mode

    • 一个为博客添加酷炫打字特效的插件
    • 使用教程

自定义内容

  • Label 徽章 / 文字模式 / mark 高亮

    • 简介

      标签插件本身没有改动,徽章、文字模式和正文 <mark> 的样式全部写在 source/_data/styles.styl 第一段(经 custom_file_path.style 注入)。写法与效果见 写作帮助文档 · Label。

    • 徽章样式开关

      source/_data/styles.styl 顶部有两个开关,改完执行 hexo clean && hexo g(或重启 hexo server)生效。都是构建期开关,不是运行时开关:只有选中的那一套会编译进 CSS,另一套不会留在产物里。

      开关一:label-scheme(外观)

      取值 效果
      soft 淡底(--bs6-X-subtle)+ 同色文字(--bs6-X-fg)+ 1px 内描边(--bs6-X-line)。正文里一行出现多个徽章也不抢字
      solid(默认) 实色底(--bs6-X)+ 对比文字(--bs6-X-on)。primary/accent/success/danger/default/inverse 配白字,太亮的 warning/info 配深色字

      开关二:label-palette(色板)

      取值 效果
      aa(默认) 保持 Bootstrap 6 的色相与饱和度,只把明度往达标方向调,让「实底徽章」「淡底徽章」「纯文字」三种用法在浅色和深色下的对比度全部 ≥ 4.5:1(WCAG AA)
      bs6 完全照搬 Bootstrap 6 官方令牌,观感和 BS 文档站一致,但 BS 本身没为徽章保证 AA

      bs6 版实测低于 4.5:1 的位置(浅色正文底 #fff、深色正文底 #333),拿来对比时心里有数:

      模式 不达标的组合
      浅色 实底 primary 3.56、success 2.82、danger 4.42;淡底 info 3.12、warning 3.65、success 3.57、primary 4.28;纯文字 info 3.71、warning 3.96、success 4.34
      深色 实底 primary 3.56、success 2.82、danger 4.42;纯文字 danger 4.03、accent 4.30

      文字模式和 mark 高亮(淡底 + 下沿实线)不受 label-scheme 影响,但两者的颜色都取自同一套色板令牌,所以 label-palette 对它们同样生效。

    • 实现要点

      • 标签插件本身未改动,两种模式与 mark 样式完全由 source/_data/styles.styl(经 _config.next.yml 的 custom_file_path.style 注入)实现,主题升级不丢失。
      • 每个类型有 5 个令牌:--bs6-X(实底)、--bs6-X-fg(文字/淡底上的字)、--bs6-X-subtle(淡底)、--bs6-X-line(描边)、--bs6-X-on(实底上的字)。徽章规则只做变量转发(--label-bg / --label-fg / --label-subtle / --label-line / --label-on),label-scheme 决定用哪几个,label-palette 决定令牌取哪一套值,两套值互不干扰。
      • 主题的 .post-body .label.danger 等规则权重是 0-3-0 且用 background 简写设了粉彩底色,因此自定义规则要写成 .post-body .label.label(重复一次类名抬到同权重)再靠文件顺序覆盖,否则会被主题压掉。
      • mark 样式与 {% label %} 渲染出的 <mark> 元素同名冲突,已用 mark:not([class]) 把规则限定在不带 class 的原生 <mark> 上;搜索页的命中词 mark.search-keyword 因此不受影响,保持主题自带的「红字 + 红色虚线下划线、背景透明」。这里踩过一次坑:自定义规则里只要给它补一行 background-color,就会把主题的 background: transparent 盖掉(同为 0-1-1 权重、自定义文件在后面),搜索结果里凭空多出黄底。
      • 色板同时写 oklch 与 hex 两份:现代浏览器用 oklch,不支持的旧浏览器由 @supports not (color: oklch(0% 0 0)) 整块替换成等价 hex。已不再使用 color-mix,深浅两套值是显式写死的常量,因此任何支持 CSS 变量的浏览器都不会退化成"只有彩色文字"。
      • 深色模式不是另一份规则,而是同一批令牌换值:theme-dark-vars() / theme-dark-hex-vars() 里把 -fg 从"基准色混黑"换成"混白",default/inverse 的底色按 BS 的 light-dark() 语义反转。所以裸写 {% label @文字 %}(走 inverse 兜底)在深色下是"近白底 + 墨字",--label-on 必须取变量而不是写死 #fff,否则会白底白字。
      • 开启 darkmode 时,文字模式前景色、default 底色以及实底徽章的文字色都会自动切换为暗色取值,写法无需任何变化。
    • 样式实现

      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
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      200
      201
      202
      203
      204
      205
      206
      207
      208
      209
      210
      211
      212
      213
      214
      215
      216
      217
      218
      219
      220
      221
      222
      223
      224
      225
      226
      227
      228
      229
      230
      231
      232
      233
      234
      235
      236
      237
      238
      239
      240
      241
      242
      243
      244
      245
      246
      247
      248
      249
      250
      251
      252
      253
      254
      255
      256
      257
      258
      259
      260
      261
      262
      263
      264
      265
      266
      267
      268
      269
      270
      271
      272
      273
      274
      275
      276
      277
      278
      279
      280
      281
      282
      283
      284
      285
      286
      287
      288
      289
      290
      291
      292
      293
      294
      295
      296
      297
      298
      299
      300
      301
      302
      303
      304
      305
      306
      307
      308
      309
      310
      311
      312
      313
      314
      315
      316
      317
      318
      319
      320
      321
      322
      323
      324
      325
      326
      327
      328
      329
      330
      331
      332
      333
      334
      335
      336
      337
      338
      339
      340
      341
      342
      343
      344
      345
      346
      347
      348
      349
      350
      351
      352
      353
      354
      355
      356
      357
      358
      359
      360
      361
      362
      363
      364
      365
      366
      367
      368
      369
      370
      371
      372
      373
      374
      375
      376
      377
      378
      379
      380
      381
      382
      383
      384
      385
      386
      387
      388
      389
      390
      391
      392
      393
      394
      395
      396
      397
      398
      399
      400
      401
      402
      403
      404
      405
      406
      407
      408
      409
      410
      411
      412
      413
      414
      415
      416
      417
      418
      419
      420
      421
      422
      423
      424
      425
      426
      427
      428
      429
      430
      431
      432
      433
      434
      435
      436
      437
      438
      439
      440
      441
      442
      443
      444
      445
      446
      447
      448
      449
      450
      451
      452
      453
      454
      455
      456
      457
      458
      459
      460
      461
      462
      463
      464
      465
      466
      467
      468
      469
      470
      471
      472
      473
      474
      475
      476
      477
      478
      479
      480
      481
      482
      483
      484
      485
      486
      487
      488
      489
      490
      491
      492
      493
      494
      495
      496
      497
      498
      499
      500
      501
      502
      503
      504
      505
      506
      507
      508
      509
      510
      511
      512
      513
      514
      515
      516
      517
      518
      519
      520
      521
      522
      523
      524
      525
      526
      527
      528
      529
      530
      531
      532
      533
      534
      535
      // ============================================================
      // 正文标记:label 徽章 / 文字模式 / mark 高亮
      // ============================================================
      // 色板取 Bootstrap 6 的 oklch 值,末尾附 @supports hex 兜底。

      // ---------- 徽章样式开关(改这两行,然后 hexo clean && hexo g)----------
      // 外观:
      // soft = 方案 B:淡底 + 同色文字 + 1px 内描边,正文里透气,一行多个也不抢字
      // solid = 方案 A:实色底 + 对比色文字,对比最强,密集出现时比较抢眼
      // 只影响「样式一:徽章」;文字模式和 mark 高亮(淡底 + 下沿实线)不受开关影响。
      label-scheme = solid
      // label-scheme = soft

      // 色板:
      // aa = 保持 BS6 的色相,把明度调到 WCAG AA 达标(实底/淡底/纯文字三处对比都 >= 4.5)
      // bs6 = 完全照搬 Bootstrap 6 官方令牌,观感最接近 BS 文档站,但不保证可读:
      // 浅色下 5/8 组低于 4.5(实底白字 success 2.82、primary 3.56、danger 4.42,
      // 淡底 warning 3.65、info 3.12),深色下 3/8(同样卡在实底白字)。
      // label-palette = aa
      label-palette = bs6

      // ---------- 色板令牌 ----------
      // --bs6-X 500 底色,实色徽章背景(BS 的 --bs-X-bg)
      // --bs6-X-fg 文字色(BS 的 --bs-X-fg:500 混黑 16%,warning 混黑 32%)
      // --bs6-X-subtle 淡底(BS 的 --bs-X-bg-subtle:500 混白 80%,深色混黑 64%)
      // --bs6-X-line 描边(BS 的 --bs-X-border:500 混白 40%,深色混黑 16%)
      // --bs6-X-on 实色底上的文字(BS 的 --bs-X-contrast:白,warning/info 用墨色)
      // 同一套值写两种形式:oklch 给现代浏览器,hex 给 @supports 兜底。
      palette-aa-light() {
      --bs6-primary: unquote('oklch(52.8% .2112 240)'); // #0071d6
      --bs6-primary-fg: unquote('oklch(44.35% .177408 240)'); // #0058a9
      --bs6-primary-subtle: unquote('oklch(90.56% .04224 240)'); // #c8e4fa
      --bs6-primary-line: unquote('oklch(71.68% .12672 240)'); // #4dadea
      --bs6-primary-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-accent: unquote('oklch(56% .26 288)'); // #7740ff
      --bs6-accent-fg: unquote('oklch(47.04% .2184 288)'); // #5d31cb
      --bs6-accent-subtle: unquote('oklch(91.2% .052 288)'); // #dfddff
      --bs6-accent-line: unquote('oklch(73.6% .156 288)'); // #a697ff
      --bs6-accent-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-success: unquote('oklch(51.2% .176 160)'); // #008241
      --bs6-success-fg: unquote('oklch(43.01% .14784 160)'); // #006631
      --bs6-success-subtle: unquote('oklch(90.24% .0352 160)'); // #cce6d7
      --bs6-success-line: unquote('oklch(70.72% .1056 160)'); // #60b58a
      --bs6-success-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-danger: unquote('oklch(57.6% .2112 20)'); // #da2641
      --bs6-danger-fg: unquote('oklch(48.38% .177408 20)'); // #ac1b32
      --bs6-danger-subtle: unquote('oklch(91.52% .04224 20)'); // #fed9d7
      --bs6-danger-line: unquote('oklch(74.56% .12672 20)'); // #f28a8b
      --bs6-danger-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-warning: unquote('oklch(88% .24 88)'); // #ffc900
      --bs6-warning-fg: unquote('oklch(52.8% .144 88)'); // #8e6300
      --bs6-warning-subtle: unquote('oklch(97.6% .048 88)'); // #fff6d3
      --bs6-warning-line: unquote('oklch(92.8% .144 88)'); // #ffe16b
      --bs6-warning-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-info: unquote('oklch(69% .22 220)'); // #00b4f8
      --bs6-info-fg: unquote('oklch(46.92% .1496 220)'); // #006a93
      --bs6-info-subtle: unquote('oklch(93.8% .044 220)'); // #cbf2ff
      --bs6-info-line: unquote('oklch(81.4% .132 220)'); // #42d5fd
      --bs6-info-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-default: unquote('oklch(92% .004 245)'); // #e2e5e7
      --bs6-default-fg: unquote('oklch(50.4% .0168 245)'); // #5d666e
      --bs6-default-subtle: unquote('oklch(96% .002 245)'); // #f1f2f3
      --bs6-default-line: unquote('oklch(76% .012 245)'); // #abb2b8
      --bs6-default-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse-fg: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse-subtle: unquote('oklch(92% .004 245)'); // #e2e5e7
      --bs6-inverse-line: unquote('oklch(68% .016 245)'); // #909aa2
      --bs6-inverse-on: unquote('oklch(100% .0 0)'); // #ffffff
      }

      palette-aa-dark() {
      --bs6-primary: unquote('oklch(52.8% .2112 240)'); // #0071d6
      --bs6-primary-fg: unquote('oklch(71.68% .12672 240)'); // #4dadea
      --bs6-primary-subtle: unquote('oklch(19.01% .076032 240)'); // #001532
      --bs6-primary-line: unquote('oklch(44.35% .177408 240)'); // #0058a9
      --bs6-primary-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-accent: unquote('oklch(56% .26 288)'); // #7740ff
      --bs6-accent-fg: unquote('oklch(73.6% .156 288)'); // #a697ff
      --bs6-accent-subtle: unquote('oklch(20.16% .0936 288)'); // #17083d
      --bs6-accent-line: unquote('oklch(47.04% .2184 288)'); // #5d31cb
      --bs6-accent-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-success: unquote('oklch(51.2% .176 160)'); // #008241
      --bs6-success-fg: unquote('oklch(70.72% .1056 160)'); // #60b58a
      --bs6-success-subtle: unquote('oklch(18.43% .06336 160)'); // #001a08
      --bs6-success-line: unquote('oklch(43.01% .14784 160)'); // #006631
      --bs6-success-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-danger: unquote('oklch(57.6% .2112 20)'); // #da2641
      --bs6-danger-fg: unquote('oklch(74.56% .12672 20)'); // #f28a8b
      --bs6-danger-subtle: unquote('oklch(20.74% .076032 20)'); // #330308
      --bs6-danger-line: unquote('oklch(48.38% .177408 20)'); // #ac1b32
      --bs6-danger-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-warning: unquote('oklch(88% .24 88)'); // #ffc900
      --bs6-warning-fg: unquote('oklch(90.4% .192 88)'); // #ffd600
      --bs6-warning-subtle: unquote('oklch(31.68% .0864 88)'); // #442e00
      --bs6-warning-line: unquote('oklch(73.92% .2016 88)'); // #df9f00
      --bs6-warning-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-info: unquote('oklch(69% .22 220)'); // #00b4f8
      --bs6-info-fg: unquote('oklch(75.2% .176 220)'); // #00c6fb
      --bs6-info-subtle: unquote('oklch(24.84% .0792 220)'); // #00283b
      --bs6-info-line: unquote('oklch(57.96% .1848 220)'); // #008ec4
      --bs6-info-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-default: unquote('oklch(50.4% .0168 245)'); // #5d666e
      --bs6-default-fg: unquote('oklch(72% .014 245)'); // #9ea6ad
      --bs6-default-subtle: unquote('oklch(31.2% .0104 245)'); // #2d3136
      --bs6-default-line: unquote('oklch(50.4% .0168 245)'); // #5d666e
      --bs6-default-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-inverse: unquote('oklch(97.6% .0012 245)'); // #f6f7f8
      --bs6-inverse-fg: unquote('oklch(84% .008 245)'); // #c6cbcf
      --bs6-inverse-subtle: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse-line: unquote('oklch(92% .004 245)'); // #e2e5e7
      --bs6-inverse-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      }

      palette-bs6-light() {
      --bs6-primary: unquote('oklch(60% .24 240)'); // #0087fe
      --bs6-primary-fg: unquote('oklch(50.4% .2016 240)'); // #006ac9
      --bs6-primary-subtle: unquote('oklch(92% .048 240)'); // #c9eaff
      --bs6-primary-line: unquote('oklch(76% .144 240)'); // #49bcff
      --bs6-primary-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-accent: unquote('oklch(56% .26 288)'); // #7740ff
      --bs6-accent-fg: unquote('oklch(47.04% .2184 288)'); // #5d31cb
      --bs6-accent-subtle: unquote('oklch(91.2% .052 288)'); // #dfddff
      --bs6-accent-line: unquote('oklch(73.6% .156 288)'); // #a697ff
      --bs6-accent-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-success: unquote('oklch(64% .22 160)'); // #00b15a
      --bs6-success-fg: unquote('oklch(53.76% .1848 160)'); // #008c45
      --bs6-success-subtle: unquote('oklch(92.8% .044 160)'); // #cff1dd
      --bs6-success-line: unquote('oklch(78.4% .132 160)'); // #60d29b
      --bs6-success-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-danger: unquote('oklch(60% .22 20)'); // #e62845
      --bs6-danger-fg: unquote('oklch(50.4% .1848 20)'); // #b61e35
      --bs6-danger-subtle: unquote('oklch(92% .044 20)'); // #ffdad8
      --bs6-danger-line: unquote('oklch(76% .132 20)'); // #fa8d8e
      --bs6-danger-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-warning: unquote('oklch(88% .24 88)'); // #ffc900
      --bs6-warning-fg: unquote('oklch(59.84% .1632 88)'); // #a87700
      --bs6-warning-subtle: unquote('oklch(97.6% .048 88)'); // #fff6d3
      --bs6-warning-line: unquote('oklch(92.8% .144 88)'); // #ffe16b
      --bs6-warning-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-info: unquote('oklch(69% .22 220)'); // #00b4f8
      --bs6-info-fg: unquote('oklch(57.96% .1848 220)'); // #008ec4
      --bs6-info-subtle: unquote('oklch(93.8% .044 220)'); // #cbf2ff
      --bs6-info-line: unquote('oklch(81.4% .132 220)'); // #42d5fd
      --bs6-info-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-default: unquote('oklch(92% .004 245)'); // #e2e5e7
      --bs6-default-fg: unquote('oklch(50.4% .0168 245)'); // #5d666e
      --bs6-default-subtle: unquote('oklch(96% .002 245)'); // #f1f2f3
      --bs6-default-line: unquote('oklch(76% .012 245)'); // #abb2b8
      --bs6-default-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse-fg: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse-subtle: unquote('oklch(92% .004 245)'); // #e2e5e7
      --bs6-inverse-line: unquote('oklch(68% .016 245)'); // #909aa2
      --bs6-inverse-on: unquote('oklch(100% .0 0)'); // #ffffff
      }

      palette-bs6-dark() {
      --bs6-primary: unquote('oklch(60% .24 240)'); // #0087fe
      --bs6-primary-fg: unquote('oklch(68% .192 240)'); // #00a3ff
      --bs6-primary-subtle: unquote('oklch(21.6% .0864 240)'); // #001c3d
      --bs6-primary-line: unquote('oklch(50.4% .2016 240)'); // #006ac9
      --bs6-primary-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-accent: unquote('oklch(56% .26 288)'); // #7740ff
      --bs6-accent-fg: unquote('oklch(69.2% .182 288)'); // #9985ff
      --bs6-accent-subtle: unquote('oklch(20.16% .0936 288)'); // #17083d
      --bs6-accent-line: unquote('oklch(47.04% .2184 288)'); // #5d31cb
      --bs6-accent-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-success: unquote('oklch(64% .22 160)'); // #00b15a
      --bs6-success-fg: unquote('oklch(71.2% .176 160)'); // #00c27b
      --bs6-success-subtle: unquote('oklch(23.04% .0792 160)'); // #00270f
      --bs6-success-line: unquote('oklch(53.76% .1848 160)'); // #008c45
      --bs6-success-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-danger: unquote('oklch(60% .22 20)'); // #e62845
      --bs6-danger-fg: unquote('oklch(68% .176 20)'); // #f1626a
      --bs6-danger-subtle: unquote('oklch(21.6% .0792 20)'); // #360309
      --bs6-danger-line: unquote('oklch(50.4% .1848 20)'); // #b61e35
      --bs6-danger-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-warning: unquote('oklch(88% .24 88)'); // #ffc900
      --bs6-warning-fg: unquote('oklch(90.4% .192 88)'); // #ffd600
      --bs6-warning-subtle: unquote('oklch(31.68% .0864 88)'); // #442e00
      --bs6-warning-line: unquote('oklch(73.92% .2016 88)'); // #df9f00
      --bs6-warning-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-info: unquote('oklch(69% .22 220)'); // #00b4f8
      --bs6-info-fg: unquote('oklch(75.2% .176 220)'); // #00c6fb
      --bs6-info-subtle: unquote('oklch(24.84% .0792 220)'); // #00283b
      --bs6-info-line: unquote('oklch(57.96% .1848 220)'); // #008ec4
      --bs6-info-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-default: unquote('oklch(50.4% .0168 245)'); // #5d666e
      --bs6-default-fg: unquote('oklch(72% .014 245)'); // #9ea6ad
      --bs6-default-subtle: unquote('oklch(31.2% .0104 245)'); // #2d3136
      --bs6-default-line: unquote('oklch(50.4% .0168 245)'); // #5d666e
      --bs6-default-on: unquote('oklch(100% .0 0)'); // #ffffff
      --bs6-inverse: unquote('oklch(97.6% .0012 245)'); // #f6f7f8
      --bs6-inverse-fg: unquote('oklch(84% .008 245)'); // #c6cbcf
      --bs6-inverse-subtle: unquote('oklch(21.6% .0072 245)'); // #171a1d
      --bs6-inverse-line: unquote('oklch(92% .004 245)'); // #e2e5e7
      --bs6-inverse-on: unquote('oklch(21.6% .0072 245)'); // #171a1d
      }

      // ---------- @supports 兜底用的 hex 版 ----------
      palette-aa-light-hex() {
      --bs6-primary: #0071d6;
      --bs6-primary-fg: #0058a9;
      --bs6-primary-subtle: #c8e4fa;
      --bs6-primary-line: #4dadea;
      --bs6-primary-on: #ffffff;
      --bs6-accent: #7740ff;
      --bs6-accent-fg: #5d31cb;
      --bs6-accent-subtle: #dfddff;
      --bs6-accent-line: #a697ff;
      --bs6-accent-on: #ffffff;
      --bs6-success: #008241;
      --bs6-success-fg: #006631;
      --bs6-success-subtle: #cce6d7;
      --bs6-success-line: #60b58a;
      --bs6-success-on: #ffffff;
      --bs6-danger: #da2641;
      --bs6-danger-fg: #ac1b32;
      --bs6-danger-subtle: #fed9d7;
      --bs6-danger-line: #f28a8b;
      --bs6-danger-on: #ffffff;
      --bs6-warning: #ffc900;
      --bs6-warning-fg: #8e6300;
      --bs6-warning-subtle: #fff6d3;
      --bs6-warning-line: #ffe16b;
      --bs6-warning-on: #171a1d;
      --bs6-info: #00b4f8;
      --bs6-info-fg: #006a93;
      --bs6-info-subtle: #cbf2ff;
      --bs6-info-line: #42d5fd;
      --bs6-info-on: #171a1d;
      --bs6-default: #e2e5e7;
      --bs6-default-fg: #5d666e;
      --bs6-default-subtle: #f1f2f3;
      --bs6-default-line: #abb2b8;
      --bs6-default-on: #171a1d;
      --bs6-inverse: #171a1d;
      --bs6-inverse-fg: #171a1d;
      --bs6-inverse-subtle: #e2e5e7;
      --bs6-inverse-line: #909aa2;
      --bs6-inverse-on: #ffffff;
      }

      palette-aa-dark-hex() {
      --bs6-primary: #0071d6;
      --bs6-primary-fg: #4dadea;
      --bs6-primary-subtle: #001532;
      --bs6-primary-line: #0058a9;
      --bs6-primary-on: #ffffff;
      --bs6-accent: #7740ff;
      --bs6-accent-fg: #a697ff;
      --bs6-accent-subtle: #17083d;
      --bs6-accent-line: #5d31cb;
      --bs6-accent-on: #ffffff;
      --bs6-success: #008241;
      --bs6-success-fg: #60b58a;
      --bs6-success-subtle: #001a08;
      --bs6-success-line: #006631;
      --bs6-success-on: #ffffff;
      --bs6-danger: #da2641;
      --bs6-danger-fg: #f28a8b;
      --bs6-danger-subtle: #330308;
      --bs6-danger-line: #ac1b32;
      --bs6-danger-on: #ffffff;
      --bs6-warning: #ffc900;
      --bs6-warning-fg: #ffd600;
      --bs6-warning-subtle: #442e00;
      --bs6-warning-line: #df9f00;
      --bs6-warning-on: #171a1d;
      --bs6-info: #00b4f8;
      --bs6-info-fg: #00c6fb;
      --bs6-info-subtle: #00283b;
      --bs6-info-line: #008ec4;
      --bs6-info-on: #171a1d;
      --bs6-default: #5d666e;
      --bs6-default-fg: #9ea6ad;
      --bs6-default-subtle: #2d3136;
      --bs6-default-line: #5d666e;
      --bs6-default-on: #ffffff;
      --bs6-inverse: #f6f7f8;
      --bs6-inverse-fg: #c6cbcf;
      --bs6-inverse-subtle: #171a1d;
      --bs6-inverse-line: #e2e5e7;
      --bs6-inverse-on: #171a1d;
      }

      palette-bs6-light-hex() {
      --bs6-primary: #0087fe;
      --bs6-primary-fg: #006ac9;
      --bs6-primary-subtle: #c9eaff;
      --bs6-primary-line: #49bcff;
      --bs6-primary-on: #ffffff;
      --bs6-accent: #7740ff;
      --bs6-accent-fg: #5d31cb;
      --bs6-accent-subtle: #dfddff;
      --bs6-accent-line: #a697ff;
      --bs6-accent-on: #ffffff;
      --bs6-success: #00b15a;
      --bs6-success-fg: #008c45;
      --bs6-success-subtle: #cff1dd;
      --bs6-success-line: #60d29b;
      --bs6-success-on: #ffffff;
      --bs6-danger: #e62845;
      --bs6-danger-fg: #b61e35;
      --bs6-danger-subtle: #ffdad8;
      --bs6-danger-line: #fa8d8e;
      --bs6-danger-on: #ffffff;
      --bs6-warning: #ffc900;
      --bs6-warning-fg: #a87700;
      --bs6-warning-subtle: #fff6d3;
      --bs6-warning-line: #ffe16b;
      --bs6-warning-on: #171a1d;
      --bs6-info: #00b4f8;
      --bs6-info-fg: #008ec4;
      --bs6-info-subtle: #cbf2ff;
      --bs6-info-line: #42d5fd;
      --bs6-info-on: #171a1d;
      --bs6-default: #e2e5e7;
      --bs6-default-fg: #5d666e;
      --bs6-default-subtle: #f1f2f3;
      --bs6-default-line: #abb2b8;
      --bs6-default-on: #171a1d;
      --bs6-inverse: #171a1d;
      --bs6-inverse-fg: #171a1d;
      --bs6-inverse-subtle: #e2e5e7;
      --bs6-inverse-line: #909aa2;
      --bs6-inverse-on: #ffffff;
      }

      palette-bs6-dark-hex() {
      --bs6-primary: #0087fe;
      --bs6-primary-fg: #00a3ff;
      --bs6-primary-subtle: #001c3d;
      --bs6-primary-line: #006ac9;
      --bs6-primary-on: #ffffff;
      --bs6-accent: #7740ff;
      --bs6-accent-fg: #9985ff;
      --bs6-accent-subtle: #17083d;
      --bs6-accent-line: #5d31cb;
      --bs6-accent-on: #ffffff;
      --bs6-success: #00b15a;
      --bs6-success-fg: #00c27b;
      --bs6-success-subtle: #00270f;
      --bs6-success-line: #008c45;
      --bs6-success-on: #ffffff;
      --bs6-danger: #e62845;
      --bs6-danger-fg: #f1626a;
      --bs6-danger-subtle: #360309;
      --bs6-danger-line: #b61e35;
      --bs6-danger-on: #ffffff;
      --bs6-warning: #ffc900;
      --bs6-warning-fg: #ffd600;
      --bs6-warning-subtle: #442e00;
      --bs6-warning-line: #df9f00;
      --bs6-warning-on: #171a1d;
      --bs6-info: #00b4f8;
      --bs6-info-fg: #00c6fb;
      --bs6-info-subtle: #00283b;
      --bs6-info-line: #008ec4;
      --bs6-info-on: #171a1d;
      --bs6-default: #5d666e;
      --bs6-default-fg: #9ea6ad;
      --bs6-default-subtle: #2d3136;
      --bs6-default-line: #5d666e;
      --bs6-default-on: #ffffff;
      --bs6-inverse: #f6f7f8;
      --bs6-inverse-fg: #c6cbcf;
      --bs6-inverse-subtle: #171a1d;
      --bs6-inverse-line: #e2e5e7;
      --bs6-inverse-on: #171a1d;
      }

      :root {
      if (label-palette == bs6) {
      palette-bs6-light();
      } else {
      palette-aa-light();
      }
      }

      // ---------- 样式一:徽章 ----------
      // solid = BS 的 .badge.theme-X(500 底色 + contrast 文字)
      // soft = BS 的 .badge-subtle.theme-X(淡底 + fg 文字 + 1px 描边)
      // 裸写 {% label @文字 %} 或类型无法识别时走基础规则 = inverse 兜底,
      // 所以 --label-on 必须取变量:深色下 inverse 底色会反转成近白,写死 #fff 就白底白字。
      .post-body .label {
      --label-bg: var(--bs6-inverse);
      --label-fg: var(--bs6-inverse-fg);
      --label-subtle: var(--bs6-inverse-subtle);
      --label-line: var(--bs6-inverse-line);
      --label-on: var(--bs6-inverse-on);
      display: inline-block;
      padding: .25em .5em;
      font-size: .875em;
      font-weight: 600;
      line-height: 1.2;
      vertical-align: baseline;
      border-radius: 4px;
      }

      // 主题自带的 .post-body .label.danger 等规则权重是 0-3-0 且用了 background 简写,
      // 这里重复一次类名把权重抬到同级,再靠文件顺序覆盖。
      if (label-scheme == soft) {
      .post-body .label.label {
      color: var(--label-fg);
      background-color: var(--label-subtle);
      box-shadow: inset 0 0 0 1px var(--label-line);
      }
      } else {
      .post-body .label.label {
      color: var(--label-on);
      background-color: var(--label-bg);
      }
      }

      .post-body .label.default { --label-bg: var(--bs6-default); --label-fg: var(--bs6-default-fg); --label-subtle: var(--bs6-default-subtle); --label-line: var(--bs6-default-line); --label-on: var(--bs6-default-on); }
      .post-body .label.primary { --label-bg: var(--bs6-primary); --label-fg: var(--bs6-primary-fg); --label-subtle: var(--bs6-primary-subtle); --label-line: var(--bs6-primary-line); --label-on: var(--bs6-primary-on); }
      .post-body .label.accent { --label-bg: var(--bs6-accent); --label-fg: var(--bs6-accent-fg); --label-subtle: var(--bs6-accent-subtle); --label-line: var(--bs6-accent-line); --label-on: var(--bs6-accent-on); }
      .post-body .label.success { --label-bg: var(--bs6-success); --label-fg: var(--bs6-success-fg); --label-subtle: var(--bs6-success-subtle); --label-line: var(--bs6-success-line); --label-on: var(--bs6-success-on); }
      .post-body .label.danger { --label-bg: var(--bs6-danger); --label-fg: var(--bs6-danger-fg); --label-subtle: var(--bs6-danger-subtle); --label-line: var(--bs6-danger-line); --label-on: var(--bs6-danger-on); }
      .post-body .label.warning { --label-bg: var(--bs6-warning); --label-fg: var(--bs6-warning-fg); --label-subtle: var(--bs6-warning-subtle); --label-line: var(--bs6-warning-line); --label-on: var(--bs6-warning-on); }
      .post-body .label.info { --label-bg: var(--bs6-info); --label-fg: var(--bs6-info-fg); --label-subtle: var(--bs6-info-subtle); --label-line: var(--bs6-info-line); --label-on: var(--bs6-info-on); }
      .post-body .label.inverse { --label-bg: var(--bs6-inverse); --label-fg: var(--bs6-inverse-fg); --label-subtle: var(--bs6-inverse-subtle); --label-line: var(--bs6-inverse-line); --label-on: var(--bs6-inverse-on); }

      // ---------- 样式二:仅文字变色(取 BS 的 --bs-X-fg)----------
      .post-body .label.text-,
      .post-body .label.text-default,
      .post-body .label.text-primary,
      .post-body .label.text-accent,
      .post-body .label.text-info,
      .post-body .label.text-success,
      .post-body .label.text-warning,
      .post-body .label.text-danger {
      padding: 0 .1em;
      font-size: 1em;
      background: none;
      box-shadow: none;
      }

      .post-body .label.text- { color: var(--text-color); }
      .post-body .label.text-default { color: var(--bs6-default-fg); }
      .post-body .label.text-primary { color: var(--bs6-primary-fg); }
      .post-body .label.text-accent { color: var(--bs6-accent-fg); }
      .post-body .label.text-success { color: var(--bs6-success-fg); }
      .post-body .label.text-danger { color: var(--bs6-danger-fg); }
      .post-body .label.text-warning { color: var(--bs6-warning-fg); }
      .post-body .label.text-info { color: var(--bs6-info-fg); }
      .post-body .label.text-inverse { color: var(--bs6-inverse-fg); }

      // ---------- 正文高亮 mark(淡黄底 + 下沿实线,比整块黄底透气)----------
      // 限定无 class 的原生 <mark>:NexT 的 {% label %} 标签渲染出来的也是 <mark>,
      // 否则徽章会被这条规则套上黄底和黄下划线。
      mark:not([class]) {
      padding: .1em .25em .12em;
      font-size: inherit;
      color: inherit;
      border-radius: 3px;
      background-color: rgba(255, 209, 90, .22);
      border-bottom: 2px solid rgba(230, 168, 24, .85);
      -webkit-box-decoration-break: clone;
      box-decoration-break: clone;
      }

      // ---------- 暗色取值 ----------
      // 同一套暗色值要作用在两种情形:跟随系统(媒体查询 + :not([data-theme=light]) 守卫,
      // 用户手动选浅色时不生效)与手动选深色(html[data-theme=dark])。
      // 宿主选择器不能用 :where() 包,否则权重低于 :root 的浅色声明,暗色变量会被压掉。
      theme-dark-vars() {
      if (label-palette == bs6) {
      palette-bs6-dark();
      } else {
      palette-aa-dark();
      }
      }

      theme-dark-mark() {
      background-color: rgba(255, 209, 90, .16);
      border-bottom-color: rgba(255, 209, 90, .5);
      color: #f2e3bf;
      }

      // 深色下的实色徽章要用 :where() 包宿主:各类型底色规则 .post-body .label.primary 是 0-3-0,
      // 宿主若写成 html[data-theme=dark] 就变成 0-3-1,会把所有类型压成同一个灰色。
      if (hexo-config('darkmode')) {
      @media (prefers-color-scheme: dark) {
      html:not([data-theme=light]) {
      theme-dark-vars();
      }

      html:where(:not([data-theme=light])) mark:not([class]) {
      theme-dark-mark();
      }
      }
      }

      html[data-theme=dark] {
      theme-dark-vars();
      }

      html:where([data-theme=dark]) mark:not([class]) {
      theme-dark-mark();
      }

      // ---------- @supports 兜底:不支持 oklch 时改用 hex ----------
      // 变量集中定义,故此处只重定义变量,上方规则自动生效。
      theme-dark-hex-vars() {
      if (label-palette == bs6) {
      palette-bs6-dark-hex();
      } else {
      palette-aa-dark-hex();
      }
      }

      @supports not (color: oklch(0% 0 0)) {
      :root {
      if (label-palette == bs6) {
      palette-bs6-light-hex();
      } else {
      palette-aa-light-hex();
      }
      }

      if (hexo-config('darkmode')) {
      @media (prefers-color-scheme: dark) {
      html:not([data-theme=light]) {
      theme-dark-hex-vars();
      }
      }
      }

      html[data-theme=dark] {
      theme-dark-hex-vars();
      }
      }
  • 试题答案隐藏 / 显示

    • 简介

      • 填空、判断、选择、问答四类题的「默认隐藏答案」。写法与示例见 写作帮助文档 · 试题答案,这里只记实现。
      • 四个文件各司其职:
        文件 职责
        scripts/tags/quiz-answer.js 注册 {% ans %} / {% qans %} 标签,渲染出 .q-ans / .q-ans-wrap 结构
        source/_data/styles.styl 全部视觉:隐藏态、显示态、按钮样式、暗色变量
        source/static/js/quiz-answer.js 交互:状态类名切换、括号自动识别、全局开关按钮、localStorage 记忆
        source/_data/body-end.njk 经 custom_file_path.bodyEnd 把上面的脚本全站注入
    • 状态类名约定

      类名 含义
      body.qz-on JS 已启用。所有隐藏样式都挂在这个类下面,所以禁用 JS、RSS、打印时答案天然可见,不需要额外的 noscript 分支
      body.qz-show-all 全局「显示全部答案」开关打开,状态存在 localStorage 的 qz-show-all 键
      .qz-paren 行内答案位于括号内(判断 / 选择题),由 JS 扫描前后文本节点自动加上,隐藏态渲染为空心方框而不是下划线
      .qz-revealed 隐藏模式下被单独点击显示的那一处答案
      .qz-off 全局显示模式下被单独点击隐藏的那一处答案
    • 实现要点

      • 填空的空白下划线用背景渐变画(linear-gradient + background-size: 100% 1px),不用 text-decoration:渐变宽度恒等于元素(文字)宽度、位置相对行盒固定,反复点击不会残缺或消失;跨行时用 box-decoration-break: clone 按行断开,不会糊成整行。
      • 隐藏态用 display: inline 而不是 inline-block:inline 的边框与背景不参与行盒高度计算,位置随文字度量,不会把行高撑开。
      • 行内答案刻意不设 min-width 和水平 padding,空白长度就等于答案文字长度,下划线才能铺满整个空白。
      • 文字与下划线之间留 var(--qz-gap, .25em) 的空隙(16px 正文约 4px,与站点链接的 text-underline-offset: 4px 同量级;实测不留时文字笔画底边与线只差 0.2px,几乎贴着)。背景只画到 padding 盒为止,把 background-position 推过 100% 会被裁掉看不见,所以空隙只能靠 padding-bottom 把盒子往下撑;inline 的垂直 padding 不参与行盒高度,行距不变。隐藏态、点击显示态、全局显示态三处共用这一个变量,空白线与显示后的线位置始终一致;括号答案的 padding: 0 .45em 简写会覆盖它,空心方框不受影响。
      • 判断 / 选择题不需要写额外标记,只要把 {% ans %} 写在括号里,JS 用 /[((]\s*$/ 与 /^\s*[))]/ 匹配前后文本节点自动识别。
      • 顶格写的 {% qans %} 会成为 .post-body 的直接子元素并截断题干列表,样式用 .post-body > .q-ans-wrap 只给一级子元素补 1.5em 左缩进(与主题的 ol/ul 缩进同值);缩进写法天然继承层级,多级列表场景一律推荐缩进写法。
      • 全局开关按钮只在文章详情页(存在 h1.post-title)且正文里确实有答案时插入首页位置;每次切换全局开关会清空所有 qz-revealed / qz-off,保证按钮语义始终是「全部」。
      • 加密文章(hexo-blog-encrypt)解密后才把正文塞进 DOM,所以用 MutationObserver 监听 .post-body,正文变化后重跑 ensureBar / markParens / syncWrapTexts。
      • 回写按钮文案前要先比较(if (t.textContent !== next)):textContent 赋值即使值相同也会替换文本节点,产生 childList 变更再次触发上面的 MutationObserver,形成无限循环把页面卡死。
    • 标签插件

      scripts/tags/quiz-answer.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
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      /* global hexo */
      /**
      * 试题答案标签插件
      *
      * 行内答案(填空 / 判断 / 选择):
      * {% ans "答案" %} (引号可省略,答案不含空格时)
      * 渲染为:<span class="q-ans">答案</span>(旧文章手写该 span 仍兼容)
      *
      * 块级答案(问答题):
      * {% qans %}
      * 答案内容(支持 Markdown)
      * {% endqans %}
      * 渲染为:.q-ans-wrap > .q-ans-toggle(按钮) + .q-ans-block(答案块)
      *
      * 缩进对齐(问答题):
      * - 顶格写:.q-ans-wrap 成为 .post-body 的直接子元素,会截断题干列表(后续题目
      * 从 <ol start="2"> 重新编号);样式给它补 1.5em 左缩进,仅能与一级列表对齐,
      * 题干在二级及更深列表时不会跟随对齐。
      * - 缩进写在列表项内(与题干文字同缩进,推荐):.q-ans-wrap 渲染进对应 <li>,
      * 天然继承所在层级缩进,多级列表也能正确对齐,且不截断列表编号。
      *
      * 隐藏 / 显示行为由 source/static/js/quiz-answer.js 与
      * source/_data/styles.styl 中的 .qz-* 规则实现:
      * - 默认隐藏答案(填空显示空白下划线,括号内答案自动显示空心方框,问答显示按钮);
      * - 隐藏模式:点击答案空白处仅显示该处,再点收起;点击按钮展开 / 收起该题;
      * - 全局开关:文章页顶部「显示全部答案 / 隐藏全部答案」,状态存入 localStorage;
      * 该模式下可点击单个答案隐藏,或点击按钮 / 答案块单独收起某题;
      * - 无 JS 环境(RSS / 打印等)下答案保持可见。
      */
      'use strict';

      function escapeHtml(str) {
      return str
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
      }

      // 行内答案:{% ans "答案" %}
      hexo.extend.tag.register('ans', args => {
      let text = args.join(' ').trim();
      // 去除成对的首尾引号(nunjucks 词法通常已去掉,此处兜底手写引号的情况)
      if (/^"[\s\S]*"$/.test(text) || /^'[\s\S]*'$/.test(text)) {
      text = text.slice(1, -1);
      }
      if (!text) return '';
      return `<span class="q-ans">${escapeHtml(text)}</span>`;
      });

      // 块级答案:{% qans %} ... {% endqans %}
      hexo.extend.tag.register('qans', (args, content) => {
      const html = hexo.render.renderSync({ text: content, engine: 'markdown' });
      return '<div class="q-ans-wrap">'
      + '<button type="button" class="q-ans-toggle">点击查看答案</button>'
      + `<div class="q-ans-block">${html}</div>`
      + '</div>';
      }, { ends: true });
    • 交互脚本

      source/static/js/quiz-answer.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
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      /* 试题答案隐藏 / 显示
      * 配合 scripts/tags/quiz-answer.js(标签渲染)与 source/_data/styles.styl(.qz-* 样式)
      *
      * DOM 结构:
      * 行内答案 .q-ans({% ans %} 渲染)
      * 问答题 .q-ans-wrap > .q-ans-toggle(按钮) + .q-ans-block(答案块)
      * 全局开关 .qz-bar > .qz-toggle,由本脚本插入到 .post-body 顶部
      *
      * 状态类名:
      * body.qz-on JS 已启用,隐藏样式生效(无 JS 时答案始终可见)
      * body.qz-show-all 全局「显示全部答案」开关打开
      * .qz-paren 行内答案位于括号内(判断 / 选择),隐藏态为空心方框
      * .qz-revealed 隐藏模式下被点击显示
      * .qz-off 全局显示模式下被点击隐藏
      *
      * 交互:
      * 隐藏模式 点击行内答案空白处显示该处(再点收起);点击按钮展开 / 收起该题
      * 全局显示模式 点击行内答案单独隐藏(再点恢复);点击按钮或答案块单独收起该题
      * 全局开关 文章页顶部按钮,切换 body.qz-show-all 并写入 localStorage;
      * 每次切换清空所有单个状态(qz-revealed / qz-off),
      * 保证按钮语义始终是「全部」
      */
      (function () {
      var KEY = 'qz-show-all';
      var body = document.body;
      if (!body) return;

      body.classList.add('qz-on');
      try {
      if (localStorage.getItem(KEY) === '1') body.classList.add('qz-show-all');
      } catch (e) { /* localStorage 不可用时忽略 */ }

      // 判断 / 选择的答案写在括号里:(<span class="q-ans">√</span>)。
      // 给这类答案加 qz-paren 标记,隐藏态渲染为空心方框(不带下划线)。
      function markParens() {
      var answers = document.querySelectorAll('.post-body .q-ans:not(.qz-paren)');
      for (var k = 0; k < answers.length; k++) {
      var prev = answers[k].previousSibling;
      var prevText = prev && prev.nodeType === 3 ? prev.nodeValue : '';
      var next = answers[k].nextSibling;
      var nextText = next && next.nodeType === 3 ? next.nodeValue : '';
      if (/[((]\s*$/.test(prevText) && /^\s*[))]/.test(nextText)) {
      answers[k].classList.add('qz-paren');
      }
      }
      }

      markParens();
      syncWrapTexts();

      // NexT 文章详情页标题为 h1.post-title;首页 / 列表页摘要为 h2.post-title
      function isPostPage() {
      return !!document.querySelector('h1.post-title');
      }

      function hasAnswer() {
      return !!document.querySelector('.post-body .q-ans, .post-body .q-ans-wrap');
      }

      function syncText(btn) {
      var show = body.classList.contains('qz-show-all');
      var next = show ? '隐藏全部答案' : '显示全部答案';
      if (btn.textContent !== next) btn.textContent = next;
      btn.setAttribute('aria-pressed', show ? 'true' : 'false');
      }

      // 问答题按钮文案跟随当前可见状态:可见 -> 「收起答案」,隐藏 -> 「点击查看答案」
      function syncWrapTexts() {
      var showAll = body.classList.contains('qz-show-all');
      var wraps = document.querySelectorAll('.q-ans-wrap');
      for (var i = 0; i < wraps.length; i++) {
      var visible = showAll
      ? !wraps[i].classList.contains('qz-off')
      : wraps[i].classList.contains('qz-revealed');
      var t = wraps[i].querySelector('.q-ans-toggle');
      if (t) {
      // 仅在文案确实变化时写入:textContent 赋值即使值相同也会替换文本节点,
      // 产生 childList 变更再触发 postBody 的 MutationObserver,形成无限循环卡死页面
      var next = visible ? '收起答案' : '点击查看答案';
      if (t.textContent !== next) t.textContent = next;
      }
      }
      }

      // 切换全局开关时清空所有单个状态(qz-revealed / qz-off),保证按钮语义始终是「全部」
      function resetIndividual() {
      var els = document.querySelectorAll('.qz-revealed, .qz-off');
      for (var i = 0; i < els.length; i++) {
      els[i].classList.remove('qz-revealed');
      els[i].classList.remove('qz-off');
      }
      syncWrapTexts();
      }

      function ensureBar() {
      if (!isPostPage()) return; // 仅文章详情页插入全局开关
      if (document.querySelector('.qz-bar')) return;
      if (!hasAnswer()) return;
      var postBody = document.querySelector('.post-body');
      if (!postBody) return;

      var bar = document.createElement('div');
      bar.className = 'qz-bar';
      var btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'qz-toggle';
      btn.addEventListener('click', function () {
      var show = body.classList.toggle('qz-show-all');
      try {
      localStorage.setItem(KEY, show ? '1' : '0');
      } catch (e) { /* ignore */ }
      resetIndividual();
      syncText(btn);
      });
      bar.appendChild(btn);
      postBody.insertBefore(bar, postBody.firstChild);
      syncText(btn);
      }

      ensureBar();

      // 加密文章(hexo-blog-encrypt)等延迟注入正文的场景:正文变化后复查开关
      var postBody = document.querySelector('.post-body');
      if (postBody && typeof MutationObserver !== 'undefined') {
      var mo = new MutationObserver(function () { ensureBar(); markParens(); syncWrapTexts(); });
      mo.observe(postBody, { childList: true, subtree: true });
      }

      document.addEventListener('click', function (e) {
      var target = e.target;
      if (!target || !target.closest) return;
      var showAll = body.classList.contains('qz-show-all');

      // 问答题按钮:切换该题答案块的展开 / 收起(两种模式下行为一致)
      var toggleBtn = target.closest('.q-ans-toggle');
      if (toggleBtn) {
      var wrap0 = toggleBtn.closest('.q-ans-wrap');
      if (!wrap0) return;
      if (showAll) {
      wrap0.classList.toggle('qz-off');
      } else {
      wrap0.classList.toggle('qz-revealed');
      }
      syncWrapTexts();
      return;
      }

      // 行内答案:隐藏模式点击显示(qz-revealed),全局显示模式点击单独隐藏(qz-off)
      var el = target.closest('.post-body .q-ans');
      if (el) {
      if (showAll) {
      el.classList.toggle('qz-off');
      } else {
      el.classList.toggle('qz-revealed');
      }
      return;
      }

      // 全局显示模式:点击已展开的问答题答案块,单独收起该题
      if (showAll) {
      var block = target.closest('.post-body .q-ans-wrap .q-ans-block');
      if (block) {
      var wrap = block.closest('.q-ans-wrap');
      if (wrap) {
      wrap.classList.add('qz-off');
      syncWrapTexts();
      }
      }
      }
      });
      })();
    • 样式

      source/_data/styles.styl
      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
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      200
      201
      202
      203
      204
      205
      206
      207
      208
      209
      210
      211
      212
      213
      214
      215
      216
      217
      218
      219
      220
      221
      222
      223
      224
      225
      226
      227
      228
      229
      230
      231
      232
      233
      234
      235
      236
      237
      238
      239
      240
      241
      242
      243
      244
      245
      246
      247
      248
      249
      250
      251
      252
      253
      254
      255
      256
      257
      258
      259
      // 四、试题答案隐藏 / 显示(quiz-answer)
      // ============================================================
      // 行内答案 {% ans "答案" %} -> <span class="q-ans">答案</span>(填空 / 判断 / 选择)
      // 块级答案 {% qans %} ... {% endqans %} -> .q-ans-wrap(问答题:按钮 + 答案块)
      // 标记方式(由 scripts/tags/quiz-answer.js 渲染):
      // 交互由 source/static/js/quiz-answer.js 提供,状态类名约定:
      // body.qz-on JS 已启用(隐藏样式仅在其下生效,无 JS 时答案可见)
      // body.qz-show-all 全局「显示全部答案」开关打开
      // .qz-paren 行内答案位于括号内(判断 / 选择),隐藏态渲染为空心方框
      // .qz-revealed 隐藏模式下,单个答案被点击显示
      // .qz-off 全局显示模式下,单个答案被点击隐藏

      // 无 JS 环境下隐藏问答题按钮(此时答案块直接可见)
      .post-body .q-ans-toggle {
      display: none;
      }

      // 顶层问答单元与题干列表对齐:主题给 .post-body ol/ul 设了 padding-left: 1.5em,
      // 而 {% qans %} 顶格写时 .q-ans-wrap 是 .post-body 的直接子元素,不补缩进就会顶到文章左缘。
      // 只匹配一级子元素:缩进写在列表项内的问答单元已继承所在层级缩进,不再叠加。
      // 顶格写仅能与一级列表对齐,多级列表请改用缩进写法(详见「写作帮助文档」)
      .post-body > .q-ans-wrap {
      margin-left: 1.5em;
      }

      body.qz-on {
      // ---------- 行内答案:默认隐藏为空白下划线 ----------
      // display: inline 让边框 / 背景不参与行盒高度计算,位置随文字度量,不扰动行布局
      .post-body .q-ans:not(.qz-revealed) {
      display: inline;
      // 不设 min-width / 水平 padding:空白长度 = 答案文字长度,
      // 下划线(背景渐变宽度恒等于文字宽度)才能铺满整个空白
      color: transparent;
      // 文字与下划线之间的空隙:背景只画到 padding 盒为止,把 background-position
      // 推过 100% 会被裁掉看不见,所以空隙只能靠 padding-bottom 把盒子往下撑。
      // inline 的垂直 padding 不参与行盒高度,不会把行距撑开;实测不加时文字笔画底边与线仅差 0.2px。
      padding-bottom: var(--qz-gap, .25em);
      // 下划线用背景渐变绘制:宽度恒等于元素(文字)宽度,位置相对行盒固定,
      // 不依赖 text-decoration 绘制路径,状态切换 / 反复点击不会残缺或消失
      background-image: linear-gradient(var(--qz-line, #999), var(--qz-line, #999));
      background-repeat: no-repeat;
      background-size: 100% 1px;
      background-position: 0 calc(100% - .05em);
      cursor: pointer;
      user-select: none;
      -webkit-box-decoration-break: clone;
      box-decoration-break: clone;
      transition: background-color .2s ease;
      }

      .post-body .q-ans:not(.qz-revealed):hover {
      background-color: var(--qz-hover, rgba(0, 0, 0, .06));
      }

      // ---------- 括号内答案(判断 / 选择):隐藏态为空心方框,不画线 ----------
      // inline 元素的边框不影响行盒高度,方框位置与括号同由字体度量决定,天然对齐
      .post-body .q-ans.qz-paren:not(.qz-revealed) {
      padding: 0 .45em;
      border: 1px solid var(--qz-line, #999);
      border-radius: 3px;
      background-image: none;
      }

      // ---------- 块级答案(问答题):默认隐藏,按钮切换 ----------
      .post-body .q-ans-wrap .q-ans-block {
      display: none;
      }

      .post-body .q-ans-wrap.qz-revealed .q-ans-block {
      display: block;
      padding: .5em .8em;
      margin: .5em 0;
      border-left: 3px solid var(--qz-accent, #60d29b);
      border-radius: 0 4px 4px 0;
      background-color: var(--qz-bg, rgba(96, 210, 155, .18));
      }

      // ---------- 已点击显示的行内答案 ----------
      .post-body .q-ans.qz-revealed {
      // 水平不加 padding,保证下划线与背景同宽;垂直空隙与隐藏态同一个变量
      padding: 0 0 var(--qz-gap, .25em);
      border-radius: 3px;
      background-color: var(--qz-bg, rgba(96, 210, 155, .18));
      color: var(--qz-fg, #1a7f4b);
      text-decoration: none;
      // 显示态下划线同样用背景渐变,与隐藏态同位置同宽度
      background-image: linear-gradient(var(--qz-fg, #1a7f4b), var(--qz-fg, #1a7f4b));
      background-repeat: no-repeat;
      background-size: 100% 1px;
      background-position: 0 calc(100% - .05em);
      cursor: pointer;
      user-select: none; // 防止连续点击触发双击选中,干扰显示状态
      -webkit-box-decoration-break: clone;
      box-decoration-break: clone;
      }

      // 括号内答案(判断 / 选择)点击显示后不画线,留白与全局显示一致
      .post-body .q-ans.qz-paren.qz-revealed {
      padding: 0 .45em;
      border: none;
      background-image: none;
      }

      // ---------- 问答题显示按钮(仅 JS 启用时可见,Bootstrap 6 outline-success 小号) ----------
      .post-body .q-ans-toggle {
      display: inline-block;
      width: auto;
      padding: .18rem .6rem;
      margin: .5em 0;
      border: 1px solid var(--bs6-success, #008758);
      border-radius: .25rem;
      background: transparent;
      color: var(--bs6-success-fg, #008c45);
      font-size: .85em;
      font-weight: 400;
      line-height: 1.5;
      cursor: pointer;
      transition: color .15s ease-in-out, background-color .15s ease-in-out,
      border-color .15s ease-in-out, box-shadow .15s ease-in-out;
      }

      .post-body .q-ans-toggle:hover {
      background-color: var(--bs6-success, #008758);
      border-color: var(--bs6-success, #008758);
      color: var(--qz-btn-hover-fg, #fff);
      }

      .post-body .q-ans-toggle:focus {
      outline: 0;
      box-shadow: 0 0 0 .25rem var(--qz-focus-success, rgba(25, 135, 84, .25));
      }

      // ---------- 全局开关:显示全部答案 ----------
      &.qz-show-all {
      // display 改回 inline(与单个点击一致),避免 inline-block 背景撑满行高造成重叠;
      // 跨行时用 clone 让背景 / 下划线按行断开,同样防止糊成整行
      .post-body .q-ans,
      .post-body .q-ans.qz-revealed {
      display: inline;
      min-width: 0;
      height: auto;
      padding: 0 0 var(--qz-gap, .25em); // 水平不加 padding 保证同宽;垂直空隙同隐藏态
      border: none;
      border-radius: 3px;
      background-color: var(--qz-bg, rgba(96, 210, 155, .18));
      color: var(--qz-fg, #1a7f4b);
      text-decoration: none;
      // 显示态下划线用背景渐变绘制,与隐藏态同位置同宽度
      background-image: linear-gradient(var(--qz-fg, #1a7f4b), var(--qz-fg, #1a7f4b));
      background-repeat: no-repeat;
      background-size: 100% 1px;
      background-position: 0 calc(100% - .05em);
      cursor: pointer; // 全局显示模式下仍可点击,单独隐藏某个答案
      vertical-align: baseline;
      -webkit-box-decoration-break: clone;
      box-decoration-break: clone;
      }

      // 括号内答案(判断 / 选择)显示后不画线,留白与隐藏方框一致
      .post-body .q-ans.qz-paren,
      .post-body .q-ans.qz-paren:not(.qz-revealed) {
      padding: 0 .45em;
      border: none;
      vertical-align: baseline;
      text-decoration: none;
      background-image: none;
      }

      // 与单独点开一致:答案展开时仍显示「收起答案」按钮
      .post-body .q-ans-toggle {
      display: inline-block;
      }

      .post-body .q-ans-wrap .q-ans-block {
      display: block;
      padding: .5em .8em;
      margin: .5em 0;
      border-left: 3px solid var(--qz-accent, #60d29b);
      border-radius: 0 4px 4px 0;
      background-color: var(--qz-bg, rgba(96, 210, 155, .18));
      }

      // ---------- 全局显示模式下被单独隐藏(.qz-off):点击隐藏,再点恢复 ----------
      .post-body .q-ans.qz-off {
      background-color: transparent;
      color: transparent;
      background-image: linear-gradient(var(--qz-line, #999), var(--qz-line, #999));
      }

      // 括号内答案(判断 / 选择)单独隐藏时恢复为空心方框
      .post-body .q-ans.qz-paren.qz-off {
      border: 1px solid var(--qz-line, #999);
      background-image: none;
      }

      // 问答题被单独收起:只藏答案块,按钮改回「点击查看答案」
      .post-body .q-ans-wrap.qz-off .q-ans-block {
      display: none;
      }
      }
      }

      // ---------- 全局开关按钮的容器(右对齐)----------
      .qz-bar {
      margin: 0 0 1em;
      text-align: right;
      }

      // ---------- 全局开关按钮(Bootstrap 6 outline-primary) ----------
      .qz-toggle {
      padding: .3rem .75rem;
      border: 1px solid var(--bs6-primary, #007cb7);
      border-radius: .375rem;
      background: transparent;
      color: var(--bs6-primary-fg, #006ac9);
      font-size: .9em;
      font-weight: 400;
      line-height: 1.5;
      cursor: pointer;
      transition: color .15s ease-in-out, background-color .15s ease-in-out,
      border-color .15s ease-in-out, box-shadow .15s ease-in-out;
      }

      .qz-toggle:hover {
      background-color: var(--bs6-primary, #007cb7);
      border-color: var(--bs6-primary, #007cb7);
      color: var(--qz-btn-hover-fg, #fff);
      }

      .qz-toggle:focus {
      outline: 0;
      box-shadow: 0 0 0 .25rem var(--qz-focus-primary, rgba(13, 110, 253, .25));
      }

      // ---------- 暗色模式 ----------
      theme-dark-qz-vars() {
      --qz-line: #666;
      --qz-muted: #9aa0a6;
      --qz-hover: rgba(255, 255, 255, .08);
      --qz-bg: rgba(96, 210, 155, .15);
      --qz-fg: #7fd8ae;
      --qz-accent: #60d29b;
      // outline 按钮暗色适配:填充底色变亮,悬停字色改用深色保证对比度
      --qz-btn-hover-fg: #0b0d0f;
      --qz-focus-primary: rgba(70, 158, 255, .35);
      --qz-focus-success: rgba(77, 200, 138, .35);
      }

      if (hexo-config('darkmode')) {
      @media (prefers-color-scheme: dark) {
      html:not([data-theme=light]) body.qz-on {
      theme-dark-qz-vars();
      }
      }
      }

      html[data-theme=dark] body.qz-on {
      theme-dark-qz-vars();
      }
  • 正文超链接

    • 简介

      正文里的链接静止状态就显色,下划线跟着文字同色,末尾带小图标:外链是 ↗(离开本站),站内互链是链接图标,页内锚点不加图标;悬停时内外链一样换成更深的颜色并显示流动波浪线。浅色和深色各给一个达到 WCAG AA 的颜色:

      场景 静止 悬停
      浅色(白底) #006ce2,对白底 4.95:1 #0055c9 + 波浪线
      深色(内容底 #333) #00a2ff,对底 4.58:1 #00c4ff + 波浪线

      染色范围是正文里不带 class 的链接,外链和站内互链都算;.btn 按钮式链接不给图标也不给波浪线,href^="#" 的页内锚点不给图标。

    • 实现要点

      • 入口是主题自己的变量:全局规则是 a { color: var(--link-color); border-bottom: 1px solid #999 },而 --link-color 在两套配色里都等于正文色,所以原来只能靠那条灰线辨认。覆盖变量比另写 color: 省事,也不用和主题抢权重。
      • 自定义属性要写在链接元素自己身上(.post-body a:not([class]))。一开始写在 .post-body 上,tabs 的导航标签(同样是不带 class 的裸链接)把新颜色继承了过去,实测发现后才补了还原规则。
      • 用 :not([class]) 筛选的依据是本站构建产物的实测统计,不是通用结论:post-body 里不带 class 的 <a> 共 2005 个,全是正文链接;带 class 的是 .btn(39 个)、tag-cloud-*(200 个)、category-list-link(66 个),正好都是不该动的组件。换到别的站点要重新数一遍再决定筛选条件。
      • light-dark() 依赖元素的 color-scheme,主题的 :root 已经声明了 light 并在深色块里换值,所以这一节和「深浅色切换」共用同一套机制,不需要另写媒体查询。
      • 图标区分语义:外链用 FontAwesome 7 的 up-right-from-square(↗),站内互链用 link(链接),两条规则共用同一份基础样式(尺寸、currentColor),只换 mask 的图形。外链的 rel 由 scripts/external-nofollow.js 补成多值 noopener external nofollow noreferrer(保留真实 href)。因此 CSS 里一律用 [rel~='noopener'] 词匹配,写成 [rel='noopener'] 精确匹配会整条失配——这个坑踩过一次。
      • ↗ 图标用 ::after + mask + background-color: currentColor 实现,不依赖 FontAwesome 能否从 CDN 加载,而且颜色自动跟随链接的静止色/悬停色。
      • 主题的 exturl 开关也能得到这个图标,但实测不合适,已放弃:它把 <a href> 换成 base64 的 <span data-url> 再由 JS 还原,静态 HTML 里没有链接目标(RSS、链接预览、无 JS 环境、搜索引擎首轮抓取拿不到),右键复制链接要等 JS;而且它的正则只处理「链接文字里没有 HTML」的外链,[`re` 官方文档](url) 这种带行内代码的会被跳过,图标忽有忽无。
      • 改完 styles.styl 或 scripts/*.js 要 hexo clean && hexo g 并重启 hexo server;浏览器还会缓存 main.css,验证时要么强刷,要么注入一份带时间戳的 <link> 再把旧的那条移除。
    • 样式

      source/_data/styles.styl
      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
      63
      64
      65
      66
      67
      68
      // ---------- 正文超链接:静止态也要能认出来 ----------
      // 主题的全局规则是 `a { color: var(--link-color); border-bottom: 1px solid #999 }`,
      // 而 --link-color 在浅/深两套配色里都等于正文色,所以链接静止时只能靠那条灰线辨认。
      // 做法是覆盖变量而不是硬写 color:自定义属性写在链接元素自己身上,
      // 只影响这一批链接;写在 .post-body 上会被组件里的裸链接(tabs 导航)继承过去。
      // 只挑「没带 class 的链接」:这是**本站**构建产物里的实测统计(不是通用结论)——正文 2005 个 <a> 全部不带 class,
      // 而 .btn 按钮(39)、标签云 tag-cloud-*(200)、分类清单 category-list-link(66)都带 class,天然排除。
      // 配色取 Bootstrap 6 的链接色相(--bs-link-color 是 oklch(60% .24 240),即 #0087fe,hue 240 的 azure 蓝),
      // 明度按 AA 调过:浅色 #006ce2 对白底 4.95:1,深色 #00a2ff 对内容底 #333 4.58:1。
      // (BS6 原色 #0087fe 对白底只有 3.56,它靠下划线补足可辨性;这里把明度从 .60 压到 .52 才达标。
      // 深色主题下 BS6 官方也是另给一个更亮的值 color-mix(in oklch, #fff 20%, 原色) = #00a3ff,思路一致。)
      .post-body a:not([class]) {
      --link-color: light-dark(#006ce2, #00a2ff);
      // 下划线跟着链接色走,比灰色实线更像链接;悬停仍然交给下面的波浪线规则
      border-bottom-color: light-dark(rgba(0, 108, 226, 0.55), rgba(0, 162, 255, 0.6));
      }

      // tabs 导航标签也是不带 class 的裸链接,会被上面那条命中,这里把主题原色还回去
      // (卡片式友情链接同理,目前站内还没有真正渲染出来的 link-grid 链接,先一起防一手)
      .post-body .tabs ul.nav-tabs li.tab a,
      .post-body .link-grid .link-grid-container a {
      --link-color: light-dark(#555, #ccc);
      }

      // ---------- 正文超链接:尾部小图标(外链 ↗ / 站内互链 链接) ----------
      // 用 mask + currentColor:图标颜色自动跟着链接的静止色/悬停色走,也不依赖 FontAwesome 能否加载成功
      // 排除 .btn:按钮式链接自己有完整的边框和内距,塞图标会挤坏版式
      .post-body a[rel~='noopener']:not(.btn)::after,
      .post-body a:not([class]):not([rel~='noopener']):not([href^='#'])::after {
      content: '';
      display: inline-block;
      width: .78em;
      height: .78em;
      margin-left: .32em;
      vertical-align: -.06em;
      background-color: currentColor;
      }

      // ↗(FontAwesome 7 solid up-right-from-square)= 点开会离开本站
      .post-body a[rel~='noopener']:not(.btn)::after {
      -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M290.4 19.8C295.4 7.8 307.1 0 320 0L480 0c17.7 0 32 14.3 32 32l0 160c0 12.9-7.8 24.6-19.8 29.6s-25.7 2.2-34.9-6.9L400 157.3 246.6 310.6c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L354.7 112 297.4 54.6c-9.2-9.2-11.9-22.9-6.9-34.9zM0 176c0-44.2 35.8-80 80-80l80 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-80 0c-8.8 0-16 7.2-16 16l0 256c0 8.8 7.2 16 16 16l256 0c8.8 0 16-7.2 16-16l0-80c0-17.7 14.3-32 32-32s32 14.3 32 32l0 80c0 44.2-35.8 80-80 80L80 512c-44.2 0-80-35.8-80-80L0 176z'/%3E%3C/svg%3E") center / contain no-repeat;
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M290.4 19.8C295.4 7.8 307.1 0 320 0L480 0c17.7 0 32 14.3 32 32l0 160c0 12.9-7.8 24.6-19.8 29.6s-25.7 2.2-34.9-6.9L400 157.3 246.6 310.6c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L354.7 112 297.4 54.6c-9.2-9.2-11.9-22.9-6.9-34.9zM0 176c0-44.2 35.8-80 80-80l80 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-80 0c-8.8 0-16 7.2-16 16l0 256c0 8.8 7.2 16 16 16l256 0c8.8 0 16-7.2 16-16l0-80c0-17.7 14.3-32 32-32s32 14.3 32 32l0 80c0 44.2-35.8 80-80 80L80 512c-44.2 0-80-35.8-80-80L0 176z'/%3E%3C/svg%3E") center / contain no-repeat;
      }

      // 链接(FontAwesome 7 solid link)= 同站互链;页内锚点(href^="#")不加图标,避免和正文里的目录链接混淆
      .post-body a:not([class]):not([rel~='noopener']):not([href^='#'])::after {
      width: .82em;
      -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath d='M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z'/%3E%3C/svg%3E") center / contain no-repeat;
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath d='M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z'/%3E%3C/svg%3E") center / contain no-repeat;
      }

      // ---------- 正文超链接:悬停显示波浪线 ----------
      // rel 现在是多值(noopener external nofollow noreferrer),所以用 ~= 词匹配,别用 = 精确匹配
      // 第二条选择器把站内互链也纳入(同样排除 .btn 和页内锚点),悬停反馈就不分内外了
      .post-body a[rel~='noopener']:hover,
      .post-body a:not([class]):not([rel~='noopener']):not([href^='#']):hover {
      color: light-dark(#0055c9, #00c4ff); // 悬停:浅色加深、深色提亮(波浪线本身仍是 #0d6efd 那条 SVG)
      background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 4'%3E%3Cpath fill='none' stroke='%230d6efd' d='M0 3.5c5 0 5-3 10-3s5 3 10 3 5-3 10-3 5 3 10 3'/%3E%3C/svg%3E") 0 100% / 20px auto repeat-x;
      animation: waveMove 0.8s linear infinite;
      border-bottom: none;
      text-decoration: none;
      }

      @keyframes waveMove {
      to {
      background-position: -20px 100%;
      }
      }
    • 外链 rel 过滤器

      scripts/external-nofollow.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
      48
      49
      50
      51
      52
      53
      54
      55
      /* global hexo */

      'use strict';

      /**
      * 给正文里的外部链接补全 rel(noopener external nofollow noreferrer)。
      *
      * 为什么不直接用主题的 exturl:exturl 会把 <a href> 换成 base64 编码的 <span data-url>,
      * 由 JS 在浏览器里再还原成 <a>。那样静态 HTML 里没有链接目标(RSS、链接预览、无 JS 环境、
      * 搜索引擎首轮抓取都拿不到),右键复制链接也要等 JS 跑完;而且它的正则只认
      * 「链接文字里没有 HTML」的链接,带行内代码的外链会被跳过,图标就忽有忽无。
      * 这里只做一件事:保留真实 <a href>,把 rel 补全,防权重外流的目的已经达到。
      *
      * 正则写成 `<a(?:\s[^>]*)?\shref=`:Markdown 刚渲染出来的是 `<a "href="...">`,只有一个空格;如果要求 `<a\s[^>]*\shref=` 就等于要两个空白位,最朴素的标签会整个漏掉。
      *
      * 优先级写 50:Hexo 自带的 external_link 过滤器用的是默认优先级 10,它会重写 <a> 并把 rel 设成
      * 「noopener」;跑在它前面会被覆盖掉,所以这里必须排在它后面。
      *
      * 注意:rel 变成多值以后,CSS 里的属性选择器要用 [rel~='noopener'](词匹配)而不是
      * [rel='noopener'](整串精确匹配),否则样式会失配。
      *
      * URL 解析用 WHATWG 的 new URL(),不用 Node 的 url.parse():后者在 Node 22+ 会打
      * [DEP0169] DeprecationWarning(构建/serve 日志里那行噪音就是它),而且行为不规范。
      * 换掉之前比对过全站 1704 个不同 href,两种写法的「同站/外链」判定结果完全一致。
      */

      /** 取主机名;解析不了(畸形 URL)就返回空串,按「不动它」处理 */
      function hostnameOf(href) {
      try {
      return new URL(href).hostname;
      } catch (err) {
      return '';
      }
      }

      const ADD = ['noopener', 'external', 'nofollow', 'noreferrer'];

      hexo.extend.filter.register('after_post_render', data => {
      const siteHost = hostnameOf(hexo.config.url) || hexo.config.url;

      data.content = data.content.replace(/<a(?:\s[^>]*)?\shref="(https?:\/\/[^"]+)"[^>]*>/ig, (match, href) => {
      const host = hostnameOf(href);
      // 同站链接与解析不了的畸形地址不动
      if (!host || host === siteHost) return match;

      const old = match.match(/\srel="([^"]*)"/i);
      const rel = (old ? old[1] : '').split(/[\s,]+/).filter(Boolean);
      ADD.forEach(key => {
      if (!rel.includes(key)) rel.push(key);
      });
      const value = ` rel="${rel.join(' ')}"`;
      // 已有 rel 就原地替换(保持属性顺序),没有就补在标签末尾
      return old ? match.replace(/\srel="[^"]*"/i, value) : match.replace(/>$/, value + '>');
      });
      }, 50);
  • CC 协议徽章本地化

    • 简介

      侧边栏和文末的 Creative Commons 徽章默认从 unpkg 加载:https://unpkg.com/@creativecommons/vocabulary@<版本>/assets/license_badges/big/by_nc_sa.svg。浏览器把它当第三方来源,控制台会刷「Tracking Prevention blocked access to storage」,CDN 不可达时还会裂图。改成引用仓库内的 source/static/img/base/by_nc_sa.svg 之后,全站 0 个页面再引用 unpkg 的徽章。

    • 实现要点

      • 这个地址来自主题的厂商清单 node_modules/hexo-theme-next/_vendors.yml(@creativecommons/vocabulary,dir: assets/license_badges),不在站点配置里,所以用一个 after_render:html 过滤器在成品 HTML 上就地替换,不去 fork 主题模板。
      • 替换前先查本地文件在不在:以后换协议(比如 by-sa)而本地没有对应的 SVG 时,就自动保留 CDN 地址,不会出现坏链接。
      • 徽章 key 里带下划线(by_nc_sa),正则的字符类要写成 [a-z0-9_-]+,漏了下划线会一条都替换不到——这个坑也踩过。
      • 需要 hexo clean && hexo g 才生效;hexo server 运行中改 scripts/ 下的文件不会自动重载。
    • 过滤器

      scripts/local-cc-badge.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
      /* global hexo */

      'use strict';

      /**
      * 把侧边栏 Creative Commons 徽章改成本地图片。
      *
      * 默认写法来自主题的厂商清单(node_modules/hexo-theme-next/_vendors.yml 里的
      * '@creativecommons/vocabulary'),渲染成 https://unpkg.com/@creativecommons/vocabulary@<版本>
      * /assets/license_badges/big/by_nc_sa.svg。第三方 CDN 有两个毛病:一是浏览器(Edge/Chrome)的
      * 跟踪防护会把它当第三方来源,控制台刷「Tracking Prevention blocked access to storage」;
      * 二是 unpkg 不可达时徽章直接裂图。
      *
      * 本地文件放在 source/static/img/base/ 下,构建时就地检查文件是否存在:
      * 存在才替换,不存在就保留 CDN 地址,避免以后换协议(比如改成 by-sa)时出现坏链接。
      */
      const fs = require('fs');
      const path = require('path');

      const BADGE_DIR = path.join(hexo.base_dir, 'source', 'static', 'img', 'base');

      // 只匹配 <img src="..."> 里的地址:after_render:html 拿到的是整页 HTML,
      // 如果不限定 src=",正文里「举例说明这个 CDN 地址」的文字也会被一起换掉(这个坑踩过:
      // 帮助文档写「默认从 unpkg 加载:<该地址>」,结果页面上显示成了本地路径)。
      // 另外 `<img(?:\s[^>]*)?\src=` 里的可选组不能省:徽章标签实际是 `<img src="...">`,写成 `<img\s[^>]*?\src=` 会要求 src 前有两个空白位,一条都匹配不上。
      const RE = /(<img(?:\s[^>]*)?\ssrc=")https:\/\/unpkg\.com\/@creativecommons\/vocabulary@[^"']*\/assets\/license_badges\/(big|small)\/([a-z0-9_-]+)\.svg("[^>]*>)/g;

      hexo.extend.filter.register('after_render:html', data => {
      return data.replace(RE, (match, head, size, key, tail) => {
      const file = path.join(BADGE_DIR, `${key}.svg`);
      if (!fs.existsSync(file)) return match;
      return `${head}/static/img/base/${key}.svg${tail}`;
      });
      }, 0);
  • 多级列表样式(六级)

    • 简介

      正文里的嵌套列表按层级各用一套标记,写 Markdown 时不需要关心实现:

      层级 有序 无序
      1 1. ●(disc)
      2 a.(lower-alpha) ○(circle)
      3 i.(lower-roman) ■(square)
      4 α.(lower-greek) □(字符串标记)
      5 ①(上限 20,超出回落十进制) ◆
      6 ➊(上限 10,超出回落十进制) ◇
    • 实现要点

      • 第 5、6 级的带圈数字用 @counter-style 定义(system: fixed 1 + symbols + fallback: decimal),比枚举 :nth-child 的 content 更标准:超出 fixed 上限时浏览器自动按 fallback 回落成十进制。
      • 无序列表 4~6 级没有对应的原生关键字,只能用字符串形式的 list-style-type: '□'(◆/◇ 同理)。字符串标记是满 em 宽的几何字形,比原生标记大一号,所以先按 li::marker { font-size } 逐级补偿(.8em / .95em / 1.05em,菱形视觉密度低于方块所以给更大字号)。
      • 符号与文字之间的距离要单独补,而且不能用「往标记里加空格」这个办法。 把页面截图按列做墨迹分析(只认灰白像素当文字,避开红色标注和 ●●● 彩点,每行第一个墨块是符号、第二个是正文)量出来:原生 square 的「符号右边缘 → 文字左边缘」约 13px,字符串 □ 只有约 5px —— 字符串标记天生贴得太紧。而 '□ '(带尾随空格)与 '□' 的截图间距都是 5px:空格只把 marker 盒宽从 8.35px 撑到 13.54px,视觉间距一点没变,所以那个写法是无效的。
      • 有效的旋钮是 li::marker { letter-spacing }:实测第 4 级盒宽 8.35px,加 8px 字距后变 16.35px(严格 +8px),说明该属性对 ::marker 生效且线性可控。现值 .85em / .72em / .65em 在三级上都约合 +10.9px,把 5px 垫到接近前三级的 13px。
      • 调这组数有个硬上限:.post-body ol, ul { padding-left: 1.5em } 在 16px 正文下正好是 24px,这是每一级可用的「装订线」宽度,marker 盒宽必须小于它,否则会压到上一级的文字上。当前三级盒宽实测 19.24 / 20.08 / 21.01px,还剩约 3px 余量,再往上调不要超过 ~23px。
      • 每条 list-style-type 前面都写一行原生关键字兜底,旧浏览器不认识 num-circled 这类自定义名称时会忽略并沿用上一行。
      • .post-body ol, ul { padding-left: 1.5em } 这个缩进值和顶格 {% qans %} 的补偿缩进是同一个数,改一处要同步改另一处。
    • 样式

      source/_data/styles.styl
      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
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      // ---------- 多级列表样式(六级)----------
      // 有序:1. → a. → i. → α. → ①(上限20) → ➊(上限10),均为 @counter-style
      // 无序:● → ○ → ■(关键词)→ □ → ◆ → ◇(字符串标记)

      // 有序第 5 级:① ② …(U+2460~U+2473,1~20);超过 20 由 decimal 接管
      @counter-style num-circled {
      system: fixed 1;
      symbols: '\2460' '\2461' '\2462' '\2463' '\2464' '\2465' '\2466' '\2467' '\2468' '\2469' '\246A' '\246B' '\246C' '\246D' '\246E' '\246F' '\2470' '\2471' '\2472' '\2473';
      suffix: ' ';
      fallback: decimal;
      }

      // 有序第 6 级:➊ ➋ …(U+278A~U+2793,1~10);超过 10 由 decimal 接管
      @counter-style num-solid {
      system: fixed 1;
      symbols: '\278A' '\278B' '\278C' '\278D' '\278E' '\278F' '\2790' '\2791' '\2792' '\2793';
      suffix: ' ';
      fallback: decimal;
      }

      .post-body {
      ol, ul {
      padding-left: 1.5em;
      }

      // ---------- 有序 ----------
      ol {
      list-style-type: decimal; // 1.
      ol {
      list-style-type: lower-alpha; // a.
      ol {
      list-style-type: lower-roman; // i.
      ol {
      list-style-type: lower-greek; // α.
      ol {
      list-style-type: lower-alpha; // 旧浏览器回落
      list-style-type: num-circled; // ①
      ol {
      list-style-type: lower-roman; // 旧浏览器回落
      list-style-type: num-solid; // ➊
      }
      }
      }
      }
      }
      }

      // ---------- 无序 ----------
      // 1~3 级用原生 disc/circle/square;4~6 级无原生关键字,用字符串标记。
      //
      // 【间距为什么要单独补】截图实测(把页面截图按列做墨迹分析,量「符号右边缘 → 文字左边缘」):
      // 原生 square(第 3 级)≈ 13px,字符串 □(第 4 级)≈ 5px —— 字符串标记天生贴得太紧。
      // 原因是 outside 标记的盒子里,字符串标记只剩字形自带的边距,而原生标记还带着 UA 的排布余量。
      // 在 ::marker 上加 letter-spacing 可以精确加宽标记盒(实测:第 4 级盒宽 8.35px → 加 8px 字距后 16.35px,
      // 正好 +8px,说明这个属性对 ::marker 生效),用它把 4~6 级的间距垫到与前三级一致。
      // 注意:往标记字符串里加空格('□ ')是**没用**的,实测盒宽变了但视觉间距没变,别再用那个写法。
      ul {
      list-style-type: disc; // ●
      ul {
      list-style-type: circle; // ○
      ul {
      list-style-type: square; // ■
      ul {
      list-style-type: '□'; // □
      li::marker {
      font-size: .8em; // 12.8px
      letter-spacing: .85em; // ≈10.9px,补上比原生少掉的那截
      }
      ul {
      list-style-type: '◆'; // ◆
      li::marker {
      font-size: .95em; // 15.2px
      letter-spacing: .72em; // ≈10.9px
      }
      ul {
      list-style-type: '◇'; // ◇
      li::marker {
      font-size: 1.05em; // 16.8px
      letter-spacing: .65em; // ≈10.9px
      }
      }
      }
      }
      }
      }
      }
      }
  • 深浅色切换

    • 简介

      主菜单末尾多一项「浅色 / 深色 / 自动」,点击循环三档,选择记在 localStorage 的 theme-scheme 键里(没有记录时默认深色)。

    • 原理

      • _config.next.yml 开了 lightdark.enable: true,主题的 _colors.styl 会把 --body-bg-color、--content-bg-color、--text-color 等一批变量定义成 light-dark(浅色, 深色)。light-dark() 取哪一支由元素上计算出来的 color-scheme 决定,所以只要翻转根元素的 color-scheme,整站配色就跟着换——这正是切换按钮做的事。
      • 同时保留主题自带的 @media (prefers-color-scheme: dark) 版本,作为「自动」档和 JS 未执行时的兜底;手动选浅色时用 html:not([data-theme=light]) 守卫把媒体查询那条挡掉。
      • 自定义色板(Bootstrap 6 那套 --bs6-*)不走 light-dark(),而是在 theme-dark-vars() / theme-dark-hex-vars() 里给同一批令牌写第二套值。这两个 mixin 的宿主选择器不能用 :where() 包::where() 权重为 0,会低于 :root 的浅色声明,暗色变量直接被压掉。
    • 首屏脚本

      必须在首屏渲染前定好主题,否则刷新时会闪一下错误配色。该文件需已在 _config.next.yml 的 custom_file_path.head 中启用。

      source/_data/head.njk
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      {# 深色/浅色手动切换:在首屏渲染前定好主题,避免刷新时闪一下 #}
      {# 状态存在 localStorage 的 theme-scheme,取值 auto | light | dark;没有记录时默认深色 #}
      <script>
      (function () {
      var KEY = 'theme-scheme';
      var stored = null;
      try { stored = localStorage.getItem(KEY); } catch (error) { /* 隐私模式下读不到,按默认值走 */ }

      var choice = stored === 'auto' || stored === 'light' || stored === 'dark' ? stored : 'dark';
      var dark = choice === 'dark' || (choice === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
      var root = document.documentElement;

      root.dataset.theme = dark ? 'dark' : 'light';
      root.dataset.themeChoice = choice;
      // 主题的配色变量都是 light-dark() 写的,翻转 color-scheme 就能整体切换
      root.style.colorScheme = dark ? 'dark' : 'light';
      })();
      </script>
    • 菜单按钮

      source/_data/body-end.njk
      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
      {# 主题切换:挂在主菜单末尾,点击在「浅色 → 深色 → 自动」之间循环(首屏取值见 _data/head.njk) #}
      <script>
      (() => {
      const KEY = 'theme-scheme';
      const ORDER = ['light', 'dark', 'auto'];
      const ICON = { light: 'fa-sun', dark: 'fa-moon', auto: 'fa-circle-half-stroke' };
      const TEXT = { light: '浅色', dark: '深色', auto: '自动' };
      const mq = window.matchMedia('(prefers-color-scheme: dark)');
      const root = document.documentElement;

      const menu = document.querySelector('.main-menu');
      if (!menu) return;

      const link = document.createElement('a');
      link.setAttribute('role', 'button');
      link.innerHTML = '<i class="fa-solid fa-fw theme-toggle-icon"></i><span class="theme-toggle-text"></span>';

      const item = document.createElement('li');
      item.className = 'menu-item menu-item-theme';
      item.appendChild(link);
      menu.appendChild(item);

      const icon = link.querySelector('.theme-toggle-icon');
      const label = link.querySelector('.theme-toggle-text');
      const choice = () => ORDER.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'dark';
      const nextOf = c => ORDER[(ORDER.indexOf(c) + 1) % ORDER.length];

      const render = () => {
      const current = choice();
      icon.className = `fa-solid fa-fw theme-toggle-icon ${ICON[current]}`;
      label.textContent = TEXT[current];
      link.title = `主题:${TEXT[current]}(当前显示${root.dataset.theme === 'dark' ? '深色' : '浅色'})· 点击切换到${TEXT[nextOf(current)]}`;
      };

      const apply = next => {
      const dark = next === 'dark' || (next === 'auto' && mq.matches);
      root.dataset.theme = dark ? 'dark' : 'light';
      root.dataset.themeChoice = next;
      root.style.colorScheme = dark ? 'dark' : 'light';
      try { localStorage.setItem(KEY, next); } catch (error) { /* 隐私模式写入失败,本次浏览内仍可切换 */ }
      render();
      };

      link.addEventListener('click', () => apply(nextOf(choice())));

      // 「自动」档下系统切换深浅色时要即时跟上
      mq.addEventListener('change', () => {
      if (choice() === 'auto') apply('auto');
      });

      render();
      })();
      </script>
    • 样式

      source/_data/styles.styl
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // ============================================================
      // 深浅色切换
      // ============================================================
      // 首屏取值见 _data/head.njk(防闪白),按钮与状态写入见 _data/body-end.njk。

      // ---------- 主题切换菜单项(脚本见 _data/body-end.njk)----------
      // 主题的 .main-menu .menu-item a 本来就是 flex + 图标 margin-right: 8px,
      // 这里再加 gap 会让文字比别的菜单项偏右,只补一个可点击的光标。
      .menu-item-theme > a {
      cursor: pointer;
      }

      // 主题里 figure.highlight 的 color-scheme 只有媒体查询版本,手动切换时跟着系统走;这里补上。
      html[data-theme=dark] figure.highlight { color-scheme: dark; }
      html[data-theme=light] figure.highlight { color-scheme: light; }
    • 已知边界

      代码块的配色不跟手动切换。主题的 --highlight-background / --highlight-foreground / --highlight-gutter-* 只写在 @media (prefers-color-scheme: dark) 里,没有进 lightdark 的 light-dark() 名单,所以只有跟随系统时才会换;手动切深浅色,代码块背景仍是系统那一套。上面第五段补的 figure.highlight { color-scheme } 只解决代码块内滚动条、选中色这类浏览器原生部分。要让代码块整体跟随手动档,得自己在 html[data-theme=dark] 下重写这四个 --highlight-* 变量(值取 _config.next.yml 里 highlight.dark.*)。

  • 代码块工具栏与折叠

    • 简介

      代码块顶部一条工具栏:左 = 语言(copy_button.style: mac 时前面再加三个圆点),中 = 文件名(有 caption 才出现,水平居中),右 = 复制 / 收起 / 全屏。正文按 codeblock.fold.height 限高,可以在「限高 → 全展开 → 只剩工具栏」之间来回;工具栏那个收起按钮每个代码块都有(点一下只剩 30px 工具栏),底部那条渐隐展开带才挑高度——没超高的块没有可展开的余量,所以不给它,也不会冒出纵向滚动条。做法对齐 Butterfly 的代码块,实现只写在 source/_data/styles.styl 与 source/_data/body-end.njk 里,node_modules 中的主题文件一行不动。

    • 相关配置

      _config.next.yml
      1
      2
      3
      4
      5
      6
      7
      8
      codeblock:
      copy_button:
      enable: true
      style: mac # default | flat | mac;mac 时工具栏左侧多三个圆点,语言字号略大
      fold:
      enable: true # 关掉就没有收起按钮与底部展开带(复制、全屏仍在)
      height: 500 # 限高像素值,内容超过它才折叠
      language: true # 关掉就不显示语言标签

      文件名写在围栏信息串里语言的后半段(python test.py 这种「语言 + 空格 + 文件名」),渲染出来就是中间那个居中的 test.py 胶囊;只写语言就没有中间那一格,右侧按钮组照样贴右。style 改完要 hexo clean 重新生成:mac 与非 mac 是 Stylus 的编译期分支,运行中的 server 不会自己换。

    • 主题原来的三个问题

      • 展开后收不回:.expand-btn 只在 .highlight-fold 下 display: block(source/css/_common/scaffolding/highlight/fold.styl),而展开逻辑(source/js/utils.js)只是把 highlight-fold 换成 unfold,.unfold 在主题里没有任何 CSS,按钮就此消失,长代码块再也收不回去。
      • 按钮贴底挡字:主题把它做成通栏条(bottom: 0),展开态一旦显示出来就压在最后一行代码上。
      • 复制多一个空行:主题取 lines.innerText,块级元素的 innerText 末尾自带一个换行,粘出来就多一行空行。这一条与折叠无关,单行代码块也一样多。
      • 另外主题的语言是右上角的大字水印(.code-lang),文件名是代码块上方单独一行 figcaption,各占一处;工具栏再显示一遍就重复了。
    • 实现要点

      • 弃用主题的两套控件:.expand-btn / .fold-cover / .code-lang 一律 display: none !important(语言水印要按任意层级隐藏,它不一定是 figure 的直接子元素),JS 里同时清掉 highlight-fold / unfold。折叠状态换成自己的四个类,全部挂在 figure.highlight 上:cb-foldable(确实超高)、cb-expanded(全展开)、cb-shrunk(只剩工具栏)、cb-fullpage(全屏)。
      • 工具栏用 grid 三段式:grid-template-columns: 1fr auto 1fr,中间列放文件名,天然水平居中;右侧 .cb-actions { justify-self: end },所以没有文件名时按钮组照样贴右,不会跟着塌到中间。
      • mac 分支在编译期决定:is_mac_header = hexo-config('codeblock.copy_button.style') == 'mac'。mac 时主题自己有一条 30px 标题栏(padding-top: 30px + ::before 画圆点),要一并让位(padding-top: 0、::before { display: none }),否则两条栏叠在一起。非 mac 风格不模仿圆点,语言标签直接贴栏左(栏本身 padding: 0 8px),不去对齐行号列。
      • 圆点为什么重画而不复用主题的:主题画的是一个 12px 实体圆加两枚 box-shadow 投影(box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b),绝对定位锚在 figure 那条 30px 标题栏上(left: 12px; margin-top: -20px)。标题栏让位之后,这套坐标会落到第一行代码上而不是工具栏里;更麻烦的是投影不占布局宽度,.cb-left 是 flex 行,只给实体圆留 12px,后面 40px 的两枚圆就直接压在语言标签上。所以改成一张 46×12 的内联 SVG 当 .cb-left::before:三个 <circle> 是真圆、46px 是真实占位宽度,flex 才能把语言标签排在圆点右边(实测距栏左 64px = 8 内边距 + 46 圆点 + 10 间距),颜色仍是主题那三个值。
      • 文件名搬进工具栏:把主题 figcaption 的子节点整体移进 .cb-file 再 replaceWith,原来那一行就没了;样式是无衬线 12.5px 小字 + rgba(0, 0, 0, .28) 胶囊底,过长用 text-overflow: ellipsis 截断。
      • 折叠裁掉、展开才滚:figure.cb-foldable > .cb-body { max-height: var(--cb-limit); overflow: hidden },折叠时两轴都裁——纵向滚动条会让折叠形同虚设,横向滚动条则会被底部展开带盖住拖不动。最初只写 overflow-y: hidden,横向按 CSS 规范被补成 auto,滚动条正好落在绝对定位的展开带下面,就是「水平滚动条拖不动」的事故。展开态改 overflow: auto 并给正文 margin-bottom: 26px,把展开带的高度让出来,横向滚动条露在它上面才能正常拖(Butterfly 折叠同样 overflow: hidden,展开用 margin-bottom: 1.8em 做同样的让位)。限高值由 JS 写进 --cb-limit,CSS 里只留 500px 兜底。
      • 收起按钮人人有份,展开带按需才给:工具栏的 .cb-shrink 只跟着 codeblock.fold.enable 走,每个代码块都挂(对齐 Butterfly 的 highlight_shrink),点一下 cb-shrunk 只留 30px 工具栏,再点还原,跟内容高度无关;底部展开带 .cb-more 才要 body.scrollHeight > limit + 8 才给——没超高的块没有可展开的余量。+8 是容差,免得只差一两像素也判成超高。fold.enable: false 时收起按钮会被摘掉并清掉 cb-shrunk。
      • 收回时按钮留在光标处:点底部展开带收回时,先记它的屏幕 top,去掉 cb-expanded 后按新旧差值 window.scrollBy 补偿,按钮就停在光标底下、代码块顶部回到视野,不割裂。Butterfly 也有这个手感,但它的 expandCode 只有一句 class 切换,实际是 Chrome 的 scroll anchoring 碰巧补的(它的展开条在 DOM 里排在代码表格前面);我们的展开条在 .cb-body 内,锚点选不到它,所以显式补偿。展开时不补偿,视线留在代码开头。配套在 .post-body 上写 overflow-anchor: none 关掉浏览器的锚点补偿,否则它会再补一次、和脚本叠成两倍跳变;页面顶部的块补偿量会被 scrollBy 自然夹住,不会滚出负值。
      • 正文宿主不写死:.cb-body 依次找 .code-container → .table-container → pre → table,因为主题会随 copy_button.style 换包裹层。
      • 复制只接管文本,按钮复用主题的:.copy-btn 和反馈图标 fa-check-circle / fa-times-circle 都是主题建的,我们只把它搬进工具栏右侧。要接管的是写进剪贴板的内容:主题 code = lines.innerText 原样写入,块级元素末尾自带一个换行(就是「复制多一个空行」),没有配置项可改,改它只能动 node_modules。文本用 lines.innerText.replace(/\n+$/, ''),只削尾部、代码内部原有的空行照旧保留。监听挂在 document 的捕获阶段并 stopPropagation():主题的监听在按钮自身(冒泡),不拦的话同一次点击里它随后又把带尾换行的文本写一遍,剪贴板最终是脏的那份。另外主题在 navigator.clipboard 缺失时(非安全上下文,例如用 http://局域网IP 访问)直接亮失败图标、没有兜底,这里退回 textarea + execCommand,这种场景也能复制;图标复位也从主题的 mouseleave 改成 1200ms 定时,鼠标停着不动也能看到反馈结束。
      • 全屏:position: fixed 铺满视口(z-index: 1300,压过主题的搜索弹层),正文 height: calc(100vh - 30px),也只有这一态允许 overflow: auto。进入前先去掉 cb-shrunk;Esc 与 pjax:success 都会退出,免得换页后留一个覆盖全屏的框。
      • 组装时机:主题要先建好 .copy-btn 和 CONFIG,所以 decorate() 放在 window load 之后跑,并补 300ms / 1200ms 两次兜底重试;pjax:success 重新组装,hexo-blog-decrypt 事件照顾加密文章(解密后正文才进 DOM)。监听只挂一次(window.codeBlockTweaksBound 标志),因为 pjax 会重复执行 body-end.njk 里的脚本。
      • 锚点落点校正:带 #hash 打开页面时浏览器已经跳完位置,工具栏才插进正文——目标上方每个代码块凭空多出 30px(线上实测 3S8HSZY 这篇「自定义内容」上方 35 个块 = 1050px),标题就此被顶出视口,看着就是「点了链接没跳到对的地方」;正文又关着 scroll anchoring,浏览器那侧也不会补。所以脚本每次装饰完核对一次 hash 目标的位置,偏多少补多少(window.scrollBy({ top: delta, behavior: 'instant' }))。只在跳转后 3 秒这一小段里生效,期间用户自己滚过(滚轮 / 触摸 / 键盘)就不再打扰,免得人在读别处还被拽回标题;慢网络下 3 秒窗口从 load 重新起算,hexo-blog-decrypt 也重新起算,因为加密文章要到解密时正文才进 DOM。页内点目录走浏览器原生跳转,本来就准,hashchange 后延后 400ms 再核一次,管住跳转之后才发生的高度变化(图片、字体)。
    • 样式

      source/_data/styles.styl
      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
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      200
      201
      202
      203
      204
      // ---------- 代码块(对齐 Butterfly 的 highlight-tools 做法)----------
      // 工具栏三段式:左 = [mac 圆点] 语言,中 = 文件名(水平居中),右 = 复制 / 收起 / 全屏。
      // 状态类都在 figure.highlight 上,由 _data/body-end.njk 判定并切换:
      // 默认 限高隐藏(不出现纵向滚动条,靠底部箭头展开)
      // .cb-foldable 内容确实超过 codeblock.fold.height 才有,只决定「底部展开带」出不出现;
      // 工具栏的收起按钮跟它无关,每个代码块都有
      // .cb-shrunk 完全收起,只剩工具栏(任何块都可以,包括没超高的)
      // .cb-expanded 全部展开
      // .cb-fullpage 全屏(fixed 覆盖,再点一次或按 Esc 退出)
      // 主题自己的折叠与语言水印全部弃用,避免两套状态、两份语言标签打架。
      is_mac_header = hexo-config('codeblock.copy_button.style') == 'mac'

      figure.highlight {
      position: relative;
      border-radius: 8px;

      // 主题的折叠控件与右上角大字语言水印(任意层级)都不再用
      .expand-btn, .fold-cover, .code-lang {
      display: none !important;
      }

      if (is_mac_header) {
      // 主题自带的 30px mac 标题栏让位给工具栏,免得两条叠在一起
      padding-top: 0;

      &::before {
      display: none;
      }
      }
      }

      .highlight-tools {
      display: grid;
      grid-template-columns: 1fr auto 1fr; // 中间列就是文件名的位置,天然水平居中
      align-items: center;
      min-height: 30px;
      padding: 0 8px;
      background-color: var(--highlight-gutter-background);
      color: var(--highlight-foreground);
      font-size: 14px;

      .cb-left {
      grid-column: 1;
      display: flex;
      align-items: center;
      gap: 10px;
      min-width: 0;
      }

      // 只有 mac 风格画圆点;其它风格语言标签直接贴工具栏最左侧(左对齐),不去对齐行号列
      if (is_mac_header) {
      // 主题那三个圆点不能直接复用:它是一个 12px 实体圆 + 两枚 box-shadow 投影,
      // 绝对定位锚在 figure 自己的 30px 标题栏上(padding-top: 30px + margin-top: -20px)。
      // 标题栏让位给工具栏后,它的坐标会落到第一行代码上;而且投影不占布局宽度,
      // flex 排语言标签时只给实体圆留 12px,后两枚圆就压在文字上。
      // 所以改画一张 46x12 的内联 SVG:三个真圆、46px 真实占位,颜色沿用主题原值。
      .cb-left::before {
      content: '';
      flex: none;
      width: 46px;
      height: 12px;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='46' height='12'%3E%3Ccircle cx='6' cy='6' r='6' fill='%23fc625d'/%3E%3Ccircle cx='23' cy='6' r='6' fill='%23fdbc40'/%3E%3Ccircle cx='40' cy='6' r='6' fill='%2335cd4b'/%3E%3C/svg%3E");
      background-repeat: no-repeat;
      }
      }

      .cb-lang {
      flex: none;
      font-weight: 700;
      text-transform: uppercase;

      if (is_mac_header) {
      font-size: 15px; // 有圆点占位,稍大一点更像标题
      } else {
      font-size: 14px; // 与代码同号,贴左时不显突兀
      }
      }

      // 文件名:无衬线小字 + 半透明深色胶囊底,像编辑器里「当前文件」的标签;水平居中由 grid 中间列保证
      .cb-file {
      grid-column: 2;
      max-width: 46vw;
      padding: 2px 12px;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
      border-radius: 4px;
      background-color: rgba(0, 0, 0, .28);
      color: var(--highlight-foreground);
      font-size: 12.5px;
      letter-spacing: .02em;
      text-align: center;
      }

      .cb-actions {
      grid-column: 3;
      justify-self: end; // 没有文件名时也照样贴右
      display: flex;
      align-items: center;
      gap: 12px;
      }

      .copy-btn, .cb-shrink, .cb-fullpage {
      position: static;
      padding: 2px;
      border: 0;
      background: none;
      color: inherit;
      font-size: 14px;
      line-height: 1;
      opacity: .75;
      cursor: pointer;
      transition: opacity .2s ease-in-out;

      &:hover {
      opacity: 1;
      }
      }
      }

      // 收回代码时的滚动补偿由脚本自己做(body-end.njk:记按钮屏幕位置再 scrollBy 差值)。
      // 这里关掉正文的 scroll anchoring,否则浏览器按锚点又补一次,和脚本叠加成两倍跳变。
      // 副作用:带 #hash 打开页面时,工具栏插进正文把锚点顶下去的那 1050px 浏览器也不补了,
      // 所以 body-end.njk 里另有一段 alignHash 在每次装饰完主动核对落点。
      .post-body {
      overflow-anchor: none;
      }

      // 限高:折叠时两轴都裁掉(纵向滚动条会让折叠变摆设,横向滚动条又会被底部展开带盖住拖不动),
      // 与 Butterfly 一致:折叠不显示任何滚动条,展开后才允许横向滚动。
      figure.cb-foldable > .cb-body {
      max-height: var(--cb-limit, 500px);
      overflow: hidden;
      }

      figure.cb-expanded > .cb-body,
      figure.cb-fullpage > .cb-body {
      max-height: none;
      overflow: auto;
      }

      // 展开态给底部展开带让出它的高度:展开带绝对定位贴在 figure 下沿,
      // 不留这条空隙就会正好盖在正文的横向滚动条上,拖不动(Butterfly 用 margin-bottom: 1.8em 做同样的让位)。
      figure.cb-expanded > .cb-body {
      margin-bottom: 26px;
      }

      // 完全收起:只留工具栏
      figure.cb-shrunk > :not(.highlight-tools) {
      display: none;
      }

      // 底部展开带:贴在限高框的下沿
      .cb-more {
      position: absolute;
      right: 0;
      bottom: 0;
      left: 0;
      height: 26px;
      background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, .45));
      color: var(--highlight-foreground);
      font-size: 14px;
      line-height: 26px;
      text-align: center;
      cursor: pointer;

      &:hover {
      color: #fff;
      }
      }

      figure:not(.cb-foldable) .cb-more,
      figure.cb-shrunk .cb-more,
      figure.cb-fullpage .cb-more {
      display: none;
      }

      // 箭头随状态翻转:angle-double-down(\f103) → up(\f102);angle-down(\f107) → up(\f106)
      figure.cb-expanded .cb-more i::before {
      content: '\f102';
      }

      figure.cb-shrunk .cb-shrink i::before {
      content: '\f106';
      }

      // 全屏
      figure.cb-fullpage {
      position: fixed;
      z-index: 1300;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      margin: 0;
      border-radius: 0;
      overflow: hidden;
      background-color: var(--highlight-background);

      > .cb-body {
      height: calc(100vh - 30px);
      overflow: auto !important;
      }
      }
    • 脚本

      source/_data/body-end.njk
      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
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      200
      201
      202
      203
      204
      205
      206
      207
      208
      209
      210
      211
      212
      213
      214
      215
      216
      217
      218
      219
      220
      221
      222
      223
      224
      225
      226
      227
      228
      229
      {# 代码块工具栏(对齐 Butterfly):左 = 语言(mac 风格下前面加三个圆点),中 = 文件名,右 = 复制 / 收起 / 全屏;
      正文按 codeblock.fold.height 限高(只裁不滚);收起按钮每个块都给,底部展开带只给真超高的块。
      状态类都挂在 figure.highlight 上:cb-foldable(需要折叠)/ cb-shrunk(只剩工具栏)/ cb-expanded(全展开)/ cb-fullpage(全屏)。
      主题的折叠控件与语言水印弃用;复制顺带去掉尾随空行。样式见 styles.styl 的「代码块」一节 #}
      <script>
      (() => {
      if (window.codeBlockTweaksBound) return; // pjax 会重复执行本脚本,监听只挂一次
      window.codeBlockTweaksBound = true;

      const el = (cls, html) => {
      const node = document.createElement('span');
      node.className = cls;
      if (html !== undefined) node.innerHTML = html;
      return node;
      };

      const cfg = () => (window.CONFIG && CONFIG.codeblock) || {};

      const decorate = () => {
      const codeblock = cfg();
      const fold = codeblock.fold || {};
      const limit = parseInt(fold.height, 10) > 0 ? parseInt(fold.height, 10) : 500;
      const foldEnabled = fold.enable !== false;
      const showLang = codeblock.language !== false;

      document.querySelectorAll('.post-body figure.highlight').forEach(fig => {
      fig.classList.remove('highlight-fold', 'unfold'); // 主题的折叠状态弃用
      const themed = fig.querySelector('.code-container');
      if (themed) themed.classList.remove('highlight-fold', 'unfold');

      let body = fig.querySelector(':scope > .cb-body');
      if (!body) {
      body = themed || fig.querySelector('.table-container') || fig.querySelector('pre') || fig.querySelector('table');
      if (body) {
      body.classList.add('cb-body');
      body.style.setProperty('--cb-limit', limit + 'px');
      }
      }
      if (!body) return;

      if (!fig.querySelector(':scope > .highlight-tools')) {
      const tools = el('highlight-tools');
      const left = el('cb-left');
      const lang = [...fig.classList].find(cls => cls !== 'highlight');
      if (showLang && lang) left.appendChild(el('cb-lang', lang));
      tools.appendChild(left);

      // 文件名:主题的 figcaption 搬进工具栏中间,原来那一行取消
      const cap = fig.querySelector(':scope > figcaption');
      if (cap) {
      const file = el('cb-file');
      while (cap.firstChild) file.appendChild(cap.firstChild);
      cap.replaceWith(file);
      tools.appendChild(file);
      }
      tools.appendChild(el('cb-actions'));
      fig.insertBefore(tools, fig.firstChild);
      }

      const actions = fig.querySelector('.cb-actions');
      if (!actions) return;
      const copy = fig.querySelector('.copy-btn');
      if (copy && copy.parentElement !== actions) actions.appendChild(copy); // 监听跟着元素走
      if (!actions.querySelector('.cb-fullpage')) {
      actions.appendChild(el('cb-fullpage', '<i class="fa fa-up-right-and-down-left-from-center fa-fw"></i>'));
      actions.querySelector('.cb-fullpage').title = '全屏显示 / 退出全屏';
      }

      // 工具栏那个收起箭头**每个代码块都给**(对齐 Butterfly 的 highlight_shrink):
      // 点一下只剩工具栏,再点还原,跟高度无关。
      // 只有底部那条展开带才挑高度——没超过 codeblock.fold.height 的块没有「可展开的余量」。
      const needFold = foldEnabled && body.scrollHeight > limit + 8;
      fig.classList.toggle('cb-foldable', needFold);
      if (foldEnabled && !actions.querySelector('.cb-shrink')) {
      const shrink = el('cb-shrink', '<i class="fa fa-angle-down fa-fw"></i>');
      shrink.title = '收起代码 / 展开代码';
      actions.appendChild(shrink);
      }
      if (!foldEnabled) {
      const shrink = actions.querySelector('.cb-shrink');
      if (shrink) shrink.remove();
      fig.classList.remove('cb-shrunk');
      }
      if (!needFold) fig.classList.remove('cb-expanded');
      if (needFold && !body.querySelector(':scope > .cb-more')) {
      const more = el('cb-more', '<i class="fa fa-angle-double-down fa-fw"></i>');
      more.title = '展开全部 / 收回';
      body.appendChild(more);
      }
      // 顺序:复制 → 收起 → 全屏
      ['.copy-btn', '.cb-shrink', '.cb-fullpage'].forEach(sel => {
      const node = actions.querySelector(sel);
      if (node) actions.appendChild(node);
      });
      });
      };

      document.addEventListener('click', event => {
      const shrink = event.target.closest && event.target.closest('.cb-shrink');
      if (shrink) {
      shrink.closest('figure.highlight').classList.toggle('cb-shrunk');
      return;
      }
      const more = event.target.closest && event.target.closest('.cb-more');
      if (more) {
      const fig = more.closest('figure.highlight');
      if (fig.classList.contains('cb-expanded')) {
      // 收回时让按钮留在光标处(Butterfly 的手感):先记屏幕位置,
      // 切换后按差值补偿滚动。展开时不补偿,视线留在代码开头。
      // 正文已关 scroll anchoring(styles.styl),否则浏览器会再补一次叠成两倍。
      const before = more.getBoundingClientRect().top;
      fig.classList.remove('cb-expanded');
      window.scrollBy(0, more.getBoundingClientRect().top - before);
      } else {
      fig.classList.add('cb-expanded');
      }
      return;
      }
      const full = event.target.closest && event.target.closest('.cb-fullpage');
      if (full) {
      const fig = full.closest('figure.highlight');
      fig.classList.remove('cb-shrunk');
      fig.classList.toggle('cb-fullpage');
      }
      });

      document.addEventListener('keydown', event => {
      if (event.key !== 'Escape') return;
      document.querySelectorAll('figure.cb-fullpage').forEach(fig => fig.classList.remove('cb-fullpage'));
      });
      document.addEventListener('pjax:success', () => {
      document.querySelectorAll('figure.cb-fullpage').forEach(fig => fig.classList.remove('cb-fullpage'));
      });

      const copyText = text => {
      if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
      return new Promise((resolve, reject) => {
      const area = document.createElement('textarea');
      area.value = text;
      area.setAttribute('readonly', '');
      area.style.cssText = 'position:fixed;top:-9999px;opacity:0';
      document.body.appendChild(area);
      area.select();
      const done = document.execCommand('copy');
      area.remove();
      done ? resolve() : reject(new Error('execCommand 复制失败'));
      });
      };

      // 复制只接管「写进剪贴板的文本」:按钮与反馈图标都复用主题的 .copy-btn。
      // 主题取 lines.innerText 原样写入,块级元素末尾自带一个换行,粘出来多一个空行,且无配置可改;
      // 它的监听挂在按钮自身(冒泡),所以这里用捕获阶段 + stopPropagation,否则同一次点击里它会把脏文本再写一遍。
      // 主题在 navigator.clipboard 缺失时(非安全上下文,如 http://局域网IP)直接亮失败图标,这里补 textarea + execCommand 兜底。
      document.addEventListener('click', event => {
      const button = event.target.closest && event.target.closest('.copy-btn');
      if (!button) return;
      const host = button.closest('figure.highlight, pre, .code-container');
      const lines = host && (host.querySelector('.code') || host.querySelector('code'));
      if (!lines) return;

      event.stopPropagation();
      const icon = button.querySelector('i');
      const flash = name => {
      icon.className = name;
      setTimeout(() => { icon.className = 'fa fa-copy fa-fw'; }, 1200);
      };
      // 只去掉块级元素尾部多出来的换行,代码内部原有的空行照旧保留
      copyText(lines.innerText.replace(/\n+$/, '')).then(
      () => flash('fa fa-check-circle fa-fw'),
      () => flash('fa fa-times-circle fa-fw')
      );
      }, true);

      // 锚点落点校正:带 #hash 打开页面时,浏览器先跳完位置,工具栏才插进正文——
      // 目标上方每个代码块凭空多出 30px(线上实测 3S8HSZY 这篇「自定义内容」上方 35 个块 = 1050px),
      // 标题就被顶出视口,看起来就是「点了链接却没跳到对的地方」。正文又关着 scroll anchoring
      // (见样式那段),浏览器不会自己补回来,所以每次装饰完核对一次目标位置,偏多少补多少。
      // 只认跳转后 3 秒这一小段:期间用户自己滚过(滚轮 / 触摸 / 键盘)就不再打扰,
      // 免得他已经在读别处还被拽回标题。慢网络下 3 秒窗口从 load 重新起算。
      let jumpAt = Date.now();
      let userScrolled = false;
      ['wheel', 'touchstart', 'keydown'].forEach(type => {
      window.addEventListener(type, () => { userScrolled = true; }, { capture: true, passive: true });
      });

      const alignHash = () => {
      if (!location.hash || userScrolled || Date.now() - jumpAt > 3000) return;
      let target;
      try {
      target = document.getElementById(decodeURIComponent(location.hash.slice(1)));
      } catch {
      return; // 非法的百分号编码,不是元素 id
      }
      if (!target) return;
      const delta = Math.round(target.getBoundingClientRect().top);
      if (!delta) return;
      window.scrollBy({ top: delta, behavior: 'instant' });
      };

      // 页内点目录是浏览器直接跳,本来就准;这里延后 400ms 再核一次,管住跳转之后才来的高度变化
      //(图片、字体)。点击本身是 pointerdown / click,不算「用户滚走」,所以标志清掉、重新计时。
      window.addEventListener('hashchange', () => {
      jumpAt = Date.now();
      userScrolled = false;
      setTimeout(alignHash, 400);
      });

      const run = () => { decorate(); alignHash(); };

      // 主题的折叠 / 复制按钮和 CONFIG 都要等它自己的脚本跑完,所以延后组装并兜底重试
      window.addEventListener('load', () => {
      if (location.hash && !userScrolled) jumpAt = Date.now();
      run();
      setTimeout(run, 300);
      setTimeout(run, 1200);
      });
      document.addEventListener('pjax:success', () => {
      jumpAt = Date.now();
      userScrolled = false;
      run();
      setTimeout(run, 300);
      });
      window.addEventListener('hexo-blog-decrypt', () => {
      // 加密文章:解密时正文才进 DOM,带 #hash 打开的话此刻才谈得上落点,重新起算窗口
      if (location.hash) jumpAt = Date.now();
      run();
      });
      })();
      </script>
    • 参考

      • NexT issue #780 提的是「代码块显示编程语言」,主题已按它加了 codeblock.language 开关(本站已启用),但没有覆盖折叠收不回的问题。
      • Butterfly 的代码块 的工具栏(语言 / 文件名 / 复制 / 折叠 / 全屏)与「只裁不滚」的限高做法照它对齐;差别是这里保留 NexT 自己的 copy_button.style 开关,非 mac 风格时不模仿 mac 的圆点。
  • 缩放 / 分屏保持阅读位置

    • 简介

      拖窗口边缘缩放、Win + 方向键分屏、Ctrl + 滚轮改缩放比例之后,正文会重新排版,滚动条的像素位置对应的早就不是原来那行字,读到的地方就丢了。这里让视口变化后正在读的那一行回到原来的屏幕位置:拖拽过程中不干预,停下后一次性归位(实测变窄 / 变宽归位误差都在 0.2px 内)。实现只写在 source/_data/body-end.njk,主题一行不动。

    • 为什么浏览器自己做不到

      • Chrome 有 scroll anchoring(overflow-anchor),但它只在锚点仍留在视口内时才补偿。整篇一起变高时锚点被推出视口,anchoring 直接放弃:同源 iframe 里做真实视口缩放(860→520)实测,auto 与 none 的偏移量完全相同(2148.6px),所以这不是「把 overflow-anchor 打开」就能解决的。
      • 正文里那条 .post-body { overflow-anchor: none } 是为代码块收回的滚动补偿加的(见上一节),和这件事无关,别误删也别误怪。
    • 实现要点

      • 探针:取视口顶部 PROBE_Y = 80 那条线上、属于 .post-body 的最内层元素(elementsFromPoint 从上往下第一个命中的),记下它和它当时的 rect.top。80px 是为了避开吸顶的阅读进度条;侧栏、悬浮按钮不认,代码块全屏(.cb-fullpage)时也不认,位置交给全屏自己管。
      • 采样双轨:scroll 事件 + 150ms 定时器比较 scrollY。Chrome 把 scroll 事件放在渲染帧里派发,标签页不可见时一次都不来(实测隐藏页里 count 为 0),只靠事件的话后台读文章时整个功能失效;定时器只在 scrollY 真变过时才探测,开销可忽略。
      • 拖拽期间一次都不滚:resize 只重置一个 180ms 防抖定时器,停下后归位。边拖边 scrollBy 会在浏览器自己的重排重绘之上再叠一次整页重绘,肉眼就是拖影 / 卡顿;更早一版每个事件排 60ms / 300ms 两个补正,连续拖拽时几十个定时器叠在一起互相打架,更糟。
      • 自己滚的不重新采样:归位本身会触发 scroll 事件,不挡住的话锚点会被换成探针线上的另一个元素,下一帧又去钉新目标,来回换目标同样是拖影来源。所以 align 记下时间戳,200ms 内 refresh 直接返回。
      • 归位后再校验一次(+120ms,只补这一次,不递归排定时器):变宽时文档变矮,浏览器会异步夹住 scrollY,图片表格也晚一拍重排;没有这次校验,变宽方向实测会差约 950px。
      • 滚动显式 behavior: 'instant',将来谁给 html 加了 scroll-behavior: smooth 也不会变成一串动画。
      • 不比较 innerWidth:只有高度变化(软键盘、地址栏收起)时正文不重排,差值本来就是 0,多钉一次无代价;而且 innerWidth 是不可伪造属性,留着这个判断会让功能没法在自动化环境里验证。
      • 监听只挂一次(window.readingAnchorBound 标志);pjax 换页后清空锚点重新采样,加密文章解密后(hexo-blog-decrypt)也重新采样。
    • 脚本

      source/_data/body-end.njk
      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
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      {# 缩放 / 分屏时保持阅读位置:视口宽度一变正文就重新排版,滚动条的像素位置对应的早就不是原来那行字。
      浏览器自带的 scroll anchoring 只在锚点仍留在视口内时才补偿,整篇一起变高时它救不了(实测 auto / none 偏移量相同),
      所以这里自己维护一个探针:滚动时记下「视口顶部 PROBE_Y 那条线上最内层的正文元素」和它当时的屏幕位置,
      resize 之后把它送回原来的位置,看起来就是文字没动、只有右边留白变了。 #}
      <script>
      (() => {
      if (window.readingAnchorBound) return; // pjax 会重复执行本脚本
      window.readingAnchorBound = true;

      const PROBE_Y = 80; // 探针离视口顶部多远(避开吸顶的阅读进度条)
      let anchor = null; // { el, offset }

      const probe = () => {
      const stack = document.elementsFromPoint(Math.floor(window.innerWidth / 2), PROBE_Y);
      for (const node of stack) {
      if (!node.closest) continue;
      if (!node.closest('.post-body')) continue; // 侧栏 / 悬浮按钮等不认
      if (node.closest('.cb-fullpage')) continue; // 代码块全屏时位置由全屏自己管
      const rect = node.getBoundingClientRect();
      return { el: node, offset: rect.top };
      }
      return null;
      };

      let lastSelfScroll = 0;

      const refresh = () => {
      // 自己刚滚过的 200ms 内不重新采样:scrollBy 也会触发 scroll 事件,
      // 不挡住的话锚点会被换成探针线上的另一个元素,下一帧又去钉新目标,
      // 连续拖拽时来回换目标就是「拖影 / 卡顿」的来源。
      if (Date.now() - lastSelfScroll < 200) return;
      const found = probe();
      if (found) anchor = found; // 探不到(正文不在这一行)就沿用上一次的值
      };

      const align = (verify) => {
      if (!anchor || !anchor.el.isConnected) return;
      const delta = anchor.el.getBoundingClientRect().top - anchor.offset;
      if (Math.abs(delta) <= .5) return;
      lastSelfScroll = Date.now();
      // 显式 instant:将来谁给 html 加了 scroll-behavior: smooth 也不会变成一串动画
      window.scrollBy({ top: delta, behavior: 'instant' });
      // 变宽时文档变矮,浏览器可能异步夹住 scrollY;图片表格也会晚一拍重排,
      // 所以归位后再校验一次(只补这一次,不再递归排定时器)
      if (verify) setTimeout(() => align(false), 120);
      };

      // 采样锚点,两条路互补:
      // 1) scroll 事件——前台最及时,但 Chrome 把 scroll 事件放在渲染帧里派发,
      // 标签页不可见 / 被遮挡时一次都不来(实测隐藏标签页里 count 为 0),只靠它会整个功能失效;
      // 2) 定时器——只在 scrollY 真的变过时才探测,隐藏标签页里也会被夹到 1s 一次,够用且几乎无开销。
      window.addEventListener('scroll', refresh, { passive: true });

      let lastY = -1;
      setInterval(() => {
      const y = window.scrollY;
      if (y === lastY) return;
      lastY = y;
      refresh();
      }, 150);

      // 拖拽窗口边缘时 resize 每帧都来,但**拖拽期间一次都不滚**:
      // 边拖边 scrollBy 会在浏览器自己的重排重绘之上再叠一次整页重绘,肉眼就是拖影 / 卡顿。
      // 改成防抖:拖拽停下 180ms 后一次性 instant 归位;过程中文字随重排移动是浏览器自身行为,无法避免。
      let settle = null;
      const pin = () => {
      if (!anchor || !anchor.el.isConnected || !anchor.el.getClientRects().length) {
      anchor = probe(); // 旧探针没了(换页、被重排移除),重新取一个
      }
      clearTimeout(settle);
      settle = setTimeout(() => align(true), 180);
      };

      // 不比较 innerWidth:只有高度变化时正文不会重排,align 算出来的差值本来就是 0,
      // 多钉一次没有代价,反而省掉「窗口宽度拿不到 / 事件里读到旧值」这类边界。
      window.addEventListener('resize', pin);

      document.addEventListener('pjax:success', () => {
      anchor = null; // 换页后旧元素已脱离文档
      setTimeout(refresh, 300);
      });
      window.addEventListener('hexo-blog-decrypt', refresh);

      refresh();
      })();
      </script>
    • 已知边界

      • 拖拽过程中文字仍会随重排移动(浏览器自身行为,无法避免),归位发生在停下后约 180ms。想要「拖的时候也纹丝不动」就得边拖边滚,而那正是拖影的来源,二者取其一。
      • 探针粒度是元素:一整段 <p> 或一个 <td> 内部换行变化时,钉的是元素顶边,段内具体第几行可能略有偏移。
      • 首页 / 归档的摘要也在 .post-body 里,同样生效;没有这个结构的页面不生效,也不需要。
  • 搜索记录带入新页面

    • 简介

      站内搜索(local_search)点进文章后,正文里的命中词会被标红,但搜索框是空的——再打开弹窗得重新输入。这个模块把上一次的查询词带进新页面:打开弹窗直接就是上次的关键词和结果列表。

    • 原理

      关键词其实一直在 URL 里:hexo-generator-searchdb 生成结果链接时会追加 ?highlight=关键词(多个词用空格连接),主题的 highlightSearchWords() 就是读这个参数做正文高亮的。所以不需要额外存储,只要在新页面把这个参数回填进 .search-input 并触发一次 input,主题自己的搜索逻辑就会把结果列表重建出来。

    • 实现要点

      • 回填时机有三个:load(普通跳转)、search:loaded(索引还没拉完时,searchdb 拉完会派发这个事件)、pjax:success(开了 pjax 时的换页)。
      • input.value === q 时直接返回,避免「回填 → 触发 input → 再回填」的循环。
      • 没开搜索的页面没有 .search-input,直接跳过;URL 里没有 highlight 参数(直接打开文章)也跳过,不会凭空塞一个旧查询。
      • 监听只挂一次(window.searchQueryRestored 标志),pjax 会重复执行 body-end.njk 里的脚本。
      • 不改 URL、不动主题文件:?highlight= 保留原样,正文高亮行为不变。
    • 脚本

      source/_data/body-end.njk
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      {# 搜索记录带入新页面:搜索结果链接自带 ?highlight=关键词(hexo-generator-searchdb 加的),
      但主题只拿它做正文高亮,新页面的搜索框是空的。这里把它回填进搜索框并触发一次 input,
      让新页面打开搜索弹窗时直接是上一次的查询和结果;索引还没拉完就等 search:loaded 再补一次。 #}
      <script>
      (() => {
      if (window.searchQueryRestored) return; // pjax 会重复执行本脚本,监听只挂一次
      window.searchQueryRestored = true;

      const restore = () => {
      const input = document.querySelector('.search-input');
      if (!input) return; // 没开搜索的页面没有这个框
      const q = new URLSearchParams(location.search).get('highlight');
      if (!q || input.value === q) return; // 已经回填过就别再触发,避免循环
      input.value = q;
      input.dispatchEvent(new Event('input', { bubbles: true }));
      };

      window.addEventListener('load', restore);
      window.addEventListener('search:loaded', restore); // 索引拉完后 searchdb 会派发这个事件
      document.addEventListener('pjax:success', restore);
      })();
      </script>
    • 已知边界

      • 查询词跟着 URL 走,所以把带 ?highlight= 的链接发给别人,对方打开也会看到高亮和回填的查询——这本来就是主题高亮机制的行为,不是新增的。
      • 回填只恢复「输入框 + 结果列表」,不会自动弹出搜索窗;要不要弹窗仍由用户点搜索按钮决定。

更新Hexo

  1. 进入博客目录

    1
    cd folder

    folder为你的Hexo博客文件路径,例如:/e/Hexo

  2. 更新hexo-cli

    1
    npm install hexo-cli@latest -g
  3. 更新hexo

    1
    npm install hexo@latest

更新NexT

说明

  • 如何配置 Hexo 和 NexT ?传统的方法是将一些选项存储在站点配置文件中,其他选项存储在主题配置文件中。这种方法是适用的,但是通过获取或下载新版本来更新NexT主题并不顺利。
  • 目前 NexT 鼓励用户使用 Alternate Theme Config 进行配置。并且可以轻松地通过 Custom Files 自定义主题的布局和样式。
  • 不推荐直接修改 NexT 主题的文件。因为这可能导致错误(例如 git merge 冲突),并且在升级主题时修改的文件可能丢失。
  • 然而你也可以通过提交(Commit)、贮藏(Stash)或忽视(Discard)本地更改以绕过这种更新错误(例如 «Commit your changes or stash them before you can merge»)。具体方法请参考这里。

  1. 请确保您使用的是Hexo 5.0(或更高版本)。
  2. 在站点的根目录下创建一个配置文件,例如:_config.next.yml
  3. 将需要的NexT主题选项从主题配置文件复制到这个配置文件中。如果是第一次安装NexT,那么使用以下命令复制整个主题配置文件:
    _config.[name].yml
    1
    2
    3
    4
    # Installed through npm
    cp node_modules/hexo-theme-next/_config.yml _config.next.yml
    # Installed through Git
    cp themes/next/_config.yml _config.next.yml
  4. 通过这种方式,您的所有配置都位于主站点配置文件中。您不需要编辑主题配置文件或创建任何新文件。这样更新NexT主题时不会引发冲突或丢失文件。

更新

  • 通过 npm 安装最新版本

    1
    2
    $ cd hexo-site
    $ npm install hexo-theme-next@latest
  • 通过 git 更新到最新的 master 分支

    1
    2
    $ cd themes/next
    $ git pull

新环境运行

  1. 拷贝已有的项目文件

  2. 删除项目文件中旧的 node_modules 文件夹

  3. 全局安装 hexo 命令

    1
    npm install -g hexo-cli

    该命令在项目根目录下运行,之后的命令在项目主题目录下运行。

  4. 安装安全更新工具(必须)

    1
    npm install -g npm-check-updates

    这个工具能安全扫描你装了哪些插件,哪些能更,哪些不能更。

  5. 扫描可更新的插件

    1
    ncu

    它会列出:你现在装的插件 → 最新版本。

  6. 一键安全更新所有插件(关键)

    1
    ncu -u

    执行完,它会自动修改你的 package.json,把所有插件写到最新兼容版。

  7. 最后安装更新

    1
    npm install
  8. 自动修复漏洞(可选)

    1
    npm audit fix

    大部分安全漏洞会自动修复,deprecated 弃用警告部分无法自动消除。

常见问题

  1. Nunjucks Error:转义内容

    如果是表格中包含以上内容,建议将 markdown 表格转为 HTML 表格解决!

  2. 加密文章(hexo-blog-encrypt)没有侧栏「文章目录」

    解密之后目录也不出现。这是构建期目录被插件替换掉正文导致的,需要额外补一个 scripts/ 脚本和解密后的搬移逻辑,见上文「hexo-blog-encrypt - 与 NexT 侧栏目录(TOC)冲突」。

友情链接

  1. 自动备份 Hexo 博客源文件
  2. Hexo 添加文章时自动打开编辑器
  3. 常用 Hexo 命令