halo-plugin-extra-api
简介
一个为 Halo CMS 提供额外 API 的轻量级插件。
核心理念
CMS 的价值就是在服务端管理和处理内容,为什么要把简单的数据处理推到前端去增加复杂度呢?
让 CMS 回归"内容即数据"的本质,减少不必要的前端异步请求和动态渲染。
这个插件正是基于这个理念:
- 让复杂的逻辑在后端处理
- 前端模板只负责展示
- 为主题提供简洁的 Finder API
- 减少不必要的 JavaScript 依赖
前端动态加载方式 vs 后端服务端渲染方式
对比维度 | 前端动态加载方式 | 后端服务端渲染方式 |
---|---|---|
全页无刷兼容性(Pjax/Swup) | ❌ 需要额外处理动态内容加载 | ✅ 模板渲染,天然支持无刷 |
性能表现 | ❌ 需要额外 HTTP 请求,增加延迟 | ✅ 服务端渲染,一次性输出 |
用户体验 | ❌ 页面闪烁,先显示占位符后填充数据 | ✅ 内容立即可见,无加载状态 |
SEO 友好 | ❌ 搜索引擎难以抓取动态内容 | ✅ 服务端渲染,完全 SEO 友好 |
错误处理 | ❌ 需要处理网络失败、超时等异常 | ✅ 服务端统一异常处理,减轻主题作者心智负担 |
开发复杂度 | ❌ 需要编写 JS 代码、状态管理、DOM 操作 | ✅ 模板中直接调用,代码简洁 |
缓存策略 | ❌ 需要前端缓存逻辑或重复请求 | ✅ 可利用模板缓存和服务端缓存 |
首屏渲染 (FCP) | ❌ 需要等待 JS 执行和 API 响应 | ✅ HTML 直接包含内容,渲染更快 |
最大内容绘制 (LCP) | ❌ 动态内容加载延迟主要内容显示 | ✅ 关键内容随页面一起渲染 |
累积布局偏移 (CLS) | ❌ 内容异步加载可能导致页面跳动 | ✅ 静态布局,无意外的布局变化 |
交互响应 (INP) | ❌ JS 执行和 DOM 操作影响交互性能 | ✅ 减少 JS 负担,交互更流畅 |
功能介绍
本插件现版本已提供以下功能:
- 无需主题适配即可使用的功能:
- 代码高亮处理器(仅全量版可用)
- 需要主题适配的 Finder API:
- 文章字数统计 API
- HTML 内容字数统计 API
- 代码高亮 API(仅全量版可用)
未来将实现的功能:TODO
欢迎为此插件提 Issue,任何你需要的功能都可以在此处提出,我将在能力范围内尽力实现。
💖 支持本项目: 如果你觉得这个插件有用,点个 Star 就是对我最大的鼓励!
感谢所有支持本项目的用户和开发者,也特别感谢 Halo CMS 团队为插件生态提供的优秀平台。
版本说明
插件提供两个版本:
- 全量版:包含所有功能,包括代码高亮等依赖 JS 的相关功能。
- 轻量版:轻量级版本,不包含 JS 相关功能和相关依赖。
轻量版的优势
- 更小的插件体积
- 更快的启动速度
- 更低的内存占用
- 更低的系统性能要求
- 支持全平台(全量版仅支持以下平台:Linux ARM64、Linux x86_64、macOS ARM64、macOS x86_64、Windows x86_64)
轻量版本缺少的功能
- 代码高亮(Shiki.js 渲染)
- 其他 JS 运行时相关功能
如果您需要上述功能,请使用全量版。
全量版使用须知
⏱️ 首次使用提示: 全量版的 JavaScript 功能(如 Shiki 代码高亮)在首次调用时需要 1~3 秒进行环境初始化。这是正常现象,之后的调用速度会非常快。
⚠️ 重要: 全量版依赖 Javet 加载 Node.js 原生库(基于 JNI),受 Halo 插件架构限制,存在已知问题。
基本使用要求
- 请勿热重载更新: 请勿直接覆盖更新(如使用应用商店/插件列表的快捷更新操作)
- 正确的更新流程:
- 方法一:停止插件 → 卸载插件 → 重启 Halo → 安装新版本 → 启动插件
- 方法二:停止插件 → 卸载插件 → 安装新版本 → 重启 Halo → 启动插件
- 卸载推荐做法: 在卸载全量版后重启 Halo 确保原生库资源完全释放。
全量版已知问题
- 问题一:卸载后重新安装全量版插件,不重启就启用。调用 JS 相关 API 时会出现错误:
- 首次安装并启动: 正常工作
- 禁用后重新启用: 正常工作
- 卸载后重新安装: ❌ 会出现
JavetException: Javet library is not loaded
错误 - 重启 Halo 后: 恢复正常工作
问题一解决方案
安装好新版本插件后先别启用!
在启用新版本插件之前,请先重启 Halo CMS。
未来版本会将引擎作为前置依赖插件,更新本插件不再需要重启。
📖 技术原因分析(展开查看详细说明)
根据错误日志和 Halo 架构分析:
问题根源:
-
Halo 插件架构: 根据 Halo 开发文档 - 插件架构, Halo 使用
Spring Plugin Framework 实现插件隔离- 每个插件拥有独立的 Spring ApplicationContext
- 每个插件使用独立的 classloader 加载资源
- 插件间通过 ExtensionPoint 机制通信
- classloader 完全隔离,无法跨插件共享类或资源
-
Javet 原生库加载机制:
- Javet 需要从 JAR 中提取原生库文件(
.dll
/.so
/.dylib
)到临时目录 - JVM 通过 JNI 加载这些原生库文件
- 原生库文件一旦加载,会被 JVM 锁定
- Javet 需要从 JAR 中提取原生库文件(
-
冲突发生过程:
- 安装插件时,Javet 提取原生库文件到
C:\Users\用户名\AppData\Local\Temp\javet\进程ID\
(以 Windows 举例) - JVM 加载这些文件并锁定
- 卸载插件时,虽然 classloader 被销毁,但原生库文件仍被 JVM 锁定,无法删除
- 重新安装时,Javet 尝试提取新文件到同一位置
- 因为文件被锁定,提取失败
- Javet 初始化失败,报错
Javet library is not loaded because <null>
- 安装插件时,Javet 提取原生库文件到
典型错误日志解读:
WARN - Failed to write to ...libjavet-node-windows-x86_64.v.4.1.7.dll because it is locked
ERROR - Native Library already loaded in another classloader
ERROR - JavetException: Javet library is not loaded because <null>
这三条日志清晰地展示了整个失败过程:
- 尝试写入文件失败(文件被锁定)
- 检测到库已在其他 classloader 中加载
- 初始化失败,因为无法提取必要的库文件
为什么重启有效:
- 重启 Halo 会终止 JVM 进程
- JVM 终止时会释放所有文件锁
- 临时目录中的原生库文件会被清理
- 新的 Halo 进程启动后,Javet 可以正常提取和加载原生库
为什么 Javet 文档中提到的 JVM 参数无效:
-Djavet.lib.loading.suppress.error=true
这个参数的作用是:
- 抑制 Javet 在检测到"already loaded in another classloader"时的错误日志
- 但无法解决文件锁定问题
- 当库文件无法提取时,Javet 根本无法完成初始化
- 因此该参数在这个场景下无效
架构层面的限制:
这个问题是 JVM/JNI + 插件 classloader 隔离的固有矛盾:
- Halo 的插件隔离设计保证了安全性和稳定性
- 但也导致原生库这类 JVM 级别资源难以管理
- 类似问题在所有使用 classloader 隔离的插件系统中都存在
- 这不是 Javet 或本插件的 bug,而是架构层面的限制
相关技术文档:
TODO
展开折叠内容
- 提供随机文章 API
- 提供预计阅读时间 API,及相关配置项
- 提供图表渲染 API
- 提供公式渲染 API
- 分离 Node.js 环境支持为可选前置插件(预计 3.0 版本实现)
文档目录
处理器文档
代码高亮处理器
插件提供了自动化的代码高亮处理器,无需在模板中手动调用,即可对文章和页面内容中的代码块进行语法高亮渲染。
此功能通过 Shiki.js 渲染,仅在全量版中可用。
特点
- 双主题支持:
- 可同时渲染浅色和深色主题,便于主题切换
- 语言自动检测:
- 从
class
属性中提取语言标识(如language-java
、lang-python
)
- 从
- 容错处理:
- 渲染失败时保持原始代码块不变
- 默认自动渲染范围:
- 处理器会自动处理以下页面内容并在页面
head
注入自定义 CSS 样式:- 文章内容 (
post
) - 页面内容 (
page
)
- 文章内容 (
- 处理器会自动处理以下页面内容并在页面
- 性能说明:
- 命中缓存时响应速度极快(微秒级),首次渲染需要 1-3 秒初始化 JS 环境
- 批量处理策略:
- 智能分组:根据 V8 引擎池大小动态分配任务(例如 14 个任务 + 5 个引擎 → 5 组,每组 2-3 个任务)
- 并行执行:多个任务组并行处理,充分利用多核性能
- 批量渲染:同一组内的任务在单个引擎中通过一次 JS 通信批量处理,减少引擎切换开销
- 优先缓存:先检查缓存,只对未命中的请求进行实际渲染
- 缓存策略:
- LRU + TTL 双重策略:最多缓存 10,000 个代码块,每个条目 24 小时自动过期
- 智能去重:相同代码内容+语言+主题的重复渲染会自动去重,避免重复计算
- 缓存键:基于代码内容的 SHA-256 哈希值,避免长代码占用过多内存
配置选项
- 自动渲染: 启用之后会自动渲染文章和单页中的代码块。注:Finder API 渲染不受此配置项影响。
- 自定义注入 CSS 样式:启用自动渲染时将在页面 head 注入样式以优化 Shiki 渲染效果,默认值提供了边距调整/行号显示/基于媒体查询的明暗切换功能。
- 额外注入规则: 指定额外的页面路径规则,支持通配符。
- 默认包含:
/moments/**
,/docs/**
- 支持自定义路径,如
/custom/**
- 默认包含:
- 额外注入规则: 指定额外的页面路径规则,支持通配符。
- 明暗双倍渲染模式切换:
- 单主题模式:
- 主题: 选择单个代码高亮主题
- 双主题模式:
- 亮色主题: 浅色模式使用的主题
- 暗色主题: 深色模式使用的主题
- 亮色主题代码块类名: 浅色代码块的 CSS 类名
- 暗色主题代码块类名: 深色代码块的 CSS 类名
- 单主题模式:
支持的主题
插件内置了 118 个 Shiki 主题,包括:
- GitHub Light/Dark 系列
- One Light/Dark Pro
- Nord, Dracula, Monokai
- Material Theme 系列
- Catppuccin 系列
完整主题列表: 请在 Halo 管理后台的插件设置页面查看所有可用主题。或前往官方文档在线预览。
补充说明
- 工作原理:
- 内容处理阶段:在内容渲染时,处理器会扫描 HTML 中的
<pre><code>
结构 - 语言检测:从
class
属性中提取语言标识(如language-java
、lang-python
)
<!-- 标准 Markdown 格式 --> <pre><code class="language-java">public class Hello { }</code></pre> <!-- 简写格式 --> <pre><code class="lang-python">print("Hello World")</code></pre> <!-- 直接在 pre 标签上指定 --> <pre class="language-javascript"><code>console.log("Hello");</code></pre>
- 主题应用:根据配置应用相应的 Shiki 主题进行高亮
- 样式注入:在页面头部注入配置的 CSS
- 内容处理阶段:在内容渲染时,处理器会扫描 HTML 中的
- 错误处理:
- 不支持的语言/主题会被跳过渲染
- 渲染失败时保留原始格式
- 性能说明:
- 使用 V8 引擎池和异步处理,提升渲染效率
- 补充说明:
- 双主题模式会生成两个并列的 div 元素
Finder API 文档
文档类型定义
string
: 字符串类型int
:整数类型(实现为 BigInteger 即无限精度整数)boolean
:布尔类型(true/false)map
:映射类型(键值对)
插件本体信息相关 API
检测本插件是否启用
描述
检测 ExtraAPI 插件是否已安装并启用。建议在主题中使用本插件 API 前先进行检测,以确保插件可用性。
官方文档:插件 Finder API
参数
extra-api
- 解释:本插件的标识符(
metadata.name
)
返回值
- 类型:
boolean
- 解释:插件可用时返回 true,否则返回 false
说明
使用 pluginFinder.available('extra-api')
可以优雅地处理插件依赖,避免在插件未安装时出现模板错误,提升主题的兼容性和用户体验。
注:在此基础上可以使用 pluginFinder.available('extra-api', '2.*')
锁定大版本号,避免 API 破坏性更新时导致主题渲染报错。
示例
<!--/* 先检测插件可用性,再使用 API */-->
<th:block th:if="${pluginFinder.available('extra-api')}">
<span
th:text="|总字数:${extraApiStatsFinder.getPostWordCount()}|"
></span>
</th:block>
<!--/* 写在一个标签内也可以,th:if 的优先级比 th:text 高 */-->
<span
th:if="${pluginFinder.available('extra-api')}"
th:text="|总字数:${extraApiStatsFinder.getPostWordCount()}|"
></span>
<!--/* 自然模板写法 */-->
<span th:if="${pluginFinder.available('extra-api')}">总字数:[[${extraApiStatsFinder.getPostWordCount()}]]</span>
插件版本检测 API
Finder 名称: extraApiPluginInfoFinder
描述
提供插件版本类型检测功能,让主题或其他代码能够检测当前运行的是轻量版还是全量版插件,以便有条件地使用高级功能。
(注:以下四个 API 本质上是同一个 API,您可以选择使用其中任何一个进行主题编写。)
API 方法
// 检查是否为全量版
extraApiPluginInfoFinder.isFullVersion()
// 检查是否为轻量版
extraApiPluginInfoFinder.isLiteVersion()
// 获取版本类型字符串
extraApiPluginInfoFinder.getVersionType()
// 检查 JavaScript 功能是否可用
extraApiPluginInfoFinder.isJavaScriptAvailable()
参数
- 无
返回值
isFullVersion()
- 类型:
boolean
- 解释:全量版时返回 true,轻量版时返回 false
- 类型:
isLiteVersion()
- 类型:
boolean
- 解释:轻量版时返回 true,全量版时返回 false
- 类型:
getVersionType()
- 类型:
string
- 解释:返回 “full” 或 “lite”
- 类型:
isJavaScriptAvailable()
- 类型:
boolean
- 解释:JavaScript 功能可用时返回 true
- 类型:
补充说明
- 检测原理
- 通过检查
V8EnginePoolService
类是否存在来判断版本类型:- 全量版:包含 JavaScript 运行时,V8EnginePoolService 类存在
- 轻量版:构建时排除 js 包下所有类,V8EnginePoolService 类不存在
- 通过检查
- 应用场景
- 主题兼容性:主题可以根据插件版本提供不同的功能体验
- 用户提示:向用户说明当前版本的功能限制
- 条件渲染:仅在支持的版本中启用特定功能(主题请求不存在的 Finder API 会报错并阻止页面渲染)
- 性能说明
- 这些方法单次调用开销极小,适合在模板中频繁使用
- 首次调用性能开销比后续调用稍高,但仍在毫秒级别(首次通过反射检查,后续直接读取缓存)
使用示例
<!--/* 根据插件版本条件性显示功能 */-->
<th:block th:if="${extraApiPluginInfoFinder.isFullVersion()}">
<div th:utext="${extraApiRenderFinder.renderCodeHtml(post.content?.content)}"></div>
</th:block>
<th:block th:unless="${extraApiPluginInfoFinder.isFullVersion()}">
<p>当前使用轻量版插件,代码高亮功能不可用</p>
</th:block>
<!--/* 显示当前版本类型 */-->
<span>插件版本:[[${extraApiPluginInfoFinder.getVersionType()}]]</span>
<!--/* 检查 JavaScript 功能可用性 */-->
<div th:if="${extraApiPluginInfoFinder.isJavaScriptAvailable()}">
<p>JavaScript 功能可用,支持高级渲染功能</p>
</div>
<!--/* 结合其他条件使用 */-->
<th:block th:if="${pluginFinder.available('extra-api') and extraApiPluginInfoFinder.isFullVersion()}">
<!-- 只有在插件可用且为全量版时才显示 -->
<div th:utext="${extraApiRenderFinder.renderCodeHtml(content)}"></div>
</th:block>
统计信息 API
Finder 名称: extraApiStatsFinder
文章字数统计 API
描述
提供文章字数统计功能,支持统计单篇文章或全站文章的字数总和。可选择统计已发布版本或最新版本(含草稿)。适用于显示文章字数、阅读时间估算等场景。
API 方法(无参数)
// 统计全部文章已发布版本的总字数
extraApiStatsFinder.getPostWordCount()
参数
- 无
返回值
- 类型:
int
- 解释:字数统计结果(非负),不存在或参数缺失时返回 0
API 方法(传入映射形式参数)
// 传入映射形式参数
extraApiStatsFinder.getPostWordCount({
name: 'post-metadata-name', // 可选,未传入则统计全部文章字数总和
version: 'release' | 'draft' // 可选,默认 'release'
})
参数
- 映射形式参数:
name
- 类型:
string
- 解释:文章的
metadata.name
,可选参数。未传入时统计全部文章字数总和。
- 类型:
version
- 类型:
string
- 解释:统计版本,可选参数。
release
(未传入时默认值)或draft
。
- 类型:
返回值
- 类型:
int
- 解释:字数统计结果(非负),不存在或参数缺失时返回 0
补充说明
- 计数规则:
- 中文、日文、韩文等 CJK 字符按每个字符计 1。
- ASCII 连续字母/数字按 1 个单词计数。
- 标点符号和空格不计入统计。
- 错误处理:
- 输入为空或文章不存在时返回 0,不会抛出异常。
- 性能说明:
- 单次调用开销较小,适合在模板中直接使用。
- 启动时自动计算并缓存,仅在文章内容更新时重新计算。
使用示例
<!--/* 统计文章已发布版本的字,下面这段代码可直接用于 /templates/post.html */-->
<span th:text="${extraApiStatsFinder.getPostWordCount({name: post.metadata.name})}"></span>
<!--/* 统计文章最新版本的字数(含草稿),下面这段代码可直接用于 /templates/post.html */-->
<span th:text="${extraApiStatsFinder.getPostWordCount({name: post.metadata.name, version: 'draft'})}"></span>
<!--/* 统计全站已发布文章的总字数,下面这段代码可直接用于全部模板 */-->
<span th:text="${extraApiStatsFinder.getPostWordCount()}"></span>
<!--/* 与下方写法等价 */-->
<span th:text="${extraApiStatsFinder.getPostWordCount({})}"></span>
<!--/* 统计全站所有文章最新版本的总字数(含草稿),下面这段代码可直接用于全部模板 */-->
<span th:text="${extraApiStatsFinder.getPostWordCount({version: 'draft'})}"></span>
HTML 内容字数统计 API
描述
提供 HTML 内容字数统计功能,支持统计任意 HTML 字符串的字数。适用于统计文章内容、瞬间内容或自定义 HTML 片段的字数。
API 方法(传入字符串形式参数)
// 直接传入 HTML 内容字符串进行统计
extraApiStatsFinder.getContentWordCount(htmlContent)
参数
htmlContent
- 类型:
string
- 解释:HTML 内容字符串(必需)。即要统计字数的 HTML 内容,支持标准 HTML 格式。
- 类型:
返回值
- 类型:
int
- 解释:字数统计结果(非负),输入为空时返回 0
API 方法(传入映射形式参数)
// 传入映射形式参数
extraApiStatsFinder.getContentWordCount({
htmlContent: '<p>HTML 内容</p>' // 必需,要统计字数的 HTML 内容
})
参数
- 映射形式参数:
htmlContent
- 类型:
string
- 解释:HTML 内容字符串(必需)
- 类型:
返回值
- 类型:
int
- 解释:字数统计结果(非负),输入为空时返回 0
描述
- 计数规则:
- 自动移除 HTML 标签(包括
<script>
和<style>
标签) - 中文、日文、韩文等 CJK 字符按每个字符计 1
- ASCII 连续字母/数字按 1 个单词计数
- 标点符号和空格不计入统计
- 自动移除 HTML 标签(包括
- 错误处理:
- 输入为空或 null 时返回 0,不会抛出异常
- 性能说明:
- 单次调用开销较小,适合在模板中直接使用
- 纯计算操作,不涉及缓存和数据库查询
使用示例
<!--/* 统计任意 HTML 内容的字数 */-->
<!--/* 传入映射形式参数的写法 */-->
<span th:text="${extraApiStatsFinder.getContentWordCount({htmlContent: '<p>这是一段测试文本</p>'})}"></span>
<!--/* 传入 HTML 内容字符串的写法 */-->
<span th:text="${extraApiStatsFinder.getContentWordCount('<p>这是一段测试文本</p>')}"></span>
<!--/* 统计文章内容的字数,下面这段代码可直接用于 /templates/post.html */-->
<span th:text="${extraApiStatsFinder.getContentWordCount(post.content?.content)}"></span>
<!--/* 统计瞬间内容的字数,下面这段代码可直接用于 /templates/moment.html */-->
<span th:text="${extraApiStatsFinder.getContentWordCount(moment.spec.content?.html)}"></span>
<!--/* 在变量中使用 */-->
<div th:with="wordCount=${extraApiStatsFinder.getContentWordCount(customContent)}">
<span>字数:[[${wordCount}]]</span>
</div>
渲染 API
Finder 名称: extraApiRenderFinder
代码高亮 API
extraApiRenderFinder.renderCodeHtml(htmlContent)
参数
htmlContent
- 类型:
string
- 解释:包含代码块的 HTML 内容,通常是文章或页面的内容字段。
- 类型:
返回值
- 类型:
string
- 解释:渲染后的 HTML 内容,渲染样式已内联,渲染失败时返回原始内容
描述
- 功能特性:
- 同 处理器文档 - 代码高亮(通过 Shiki.js 渲染) 中描述的功能特性一致。也受同样的配置项影响。
使用示例
<!--/* 渲染文章内容中的代码块,下面这段代码可直接用于 /templates/post.html */-->
<div th:utext="${extraApiRenderFinder.renderCodeHtml(post.content?.content)}"></div>
<!--/* 在模板中使用,下面这段代码可直接用于 /templates/moment.html */-->
<div th:with="renderedContent=${extraApiRenderFinder.renderCodeHtml(moment.spec.content?.html)}">
<div th:utext="${renderedContent}"></div>
</div>
下载和安装
稳定版
稳定版通过 GitHub Releases 发布,建议生产环境使用。
- 访问 Releases 页面
- 下载最新版本的 JAR 文件:
extra-api-lite-版本号.jar
:轻量版(适用于所有平台)extra-api-full-all-platforms-版本号.jar
:全量版(包含所有平台依赖,体积较大,不推荐下载)- 如需使用全量版,推荐下载平台特定版本:
extra-api-full-linux-arm64-版本号.jar
:适用于 Linux ARM64 平台的版本extra-api-full-linux-x86_64-版本号.jar
:适用于 Linux x86_64 平台的版本extra-api-full-macos-arm64-版本号.jar
:适用于 macOS ARM64 平台的版本extra-api-full-macos-x86_64-版本号.jar
:适用于 macOS x86_64 平台的版本extra-api-full-windows-x86_64-版本号.jar
:适用于 Windows x86_64 平台的版本
- 将下载的 JAR 文件上传到 Halo 的插件管理页面安装
开发版
插件的开发版 JAR 文件通过 GitHub Actions 自动构建,仅用于测试和开发。
下载步骤
- 访问上述链接,选择最新的成功运行的 workflow。
- 在 “Artifacts” 部分,下载
extra-api
压缩包。 - 解压后,您将找到以下 JAR 文件:
extra-api-lite-版本号-SNAPSHOT.jar
:轻量版(适用于所有平台)extra-api-full-all-platforms-版本号-SNAPSHOT.jar
:全量版(包含所有平台依赖)extra-api-full-linux-arm64-版本号-SNAPSHOT.jar
:全量版(适用于 Linux ARM64 平台)extra-api-full-linux-x86_64-版本号-SNAPSHOT.jar
:全量版(适用于 Linux x86_64 平台)extra-api-full-macos-arm64-版本号-SNAPSHOT.jar
:全量版(适用于 macOS ARM64 平台)extra-api-full-macos-x86_64-版本号-SNAPSHOT.jar
:全量版(适用于 macOS x86_64 平台)extra-api-full-windows-x86_64-版本号-SNAPSHOT.jar
:全量版(适用于 Windows x86_64 平台)
选择适合您系统的 JAR 文件安装到 Halo。
开发指南/贡献指南
许可证
AGPL-3.0 © HowieHz