插件系统

WASM 插件、两层扩展点、权限化 host 函数与贡献机制

作者:Liangdi

插件是 WebAssembly 模块,构建时由 host 加载并按 priority 顺序(数字越小越先执行)调度。当前 API 契约版本为 v0.2

两层扩展点

  • Layer 1 — 10 个通用管线 hookon_build_start / on_build_end / on_config / on_content_discover / on_page_parse / on_markdown / on_markdown_html / on_template_context / on_template_output / on_asset
  • Layer 2 — 8 个业务域 hook,覆盖 5 个域
    • SEO:on_seo
    • Sitemap:on_sitemap
    • Search:on_search_document(逐页增强)、on_search_index(整站聚合)
    • Feed:on_feed_entry(逐条增强)、on_feed(整站聚合)
    • Inject:on_head_inject · on_body_inject
  • 贡献(contribute):插件向模板引擎注册 filter(含多参 filter_args)/ global_function / shortcode

写一个插件

use anycms_ssg_plugin_sdk::{plugin_fn, FnResult, TemplateContext};

/// 注入 reading_time 模板变量
#[plugin_fn]
pub fn on_template_context(mut ctx: TemplateContext) -> FnResult<TemplateContext> {
    let words = ctx.page.content.split_whitespace().count();
    let minutes = (words as f64 / 200.0).ceil() as u64;
    ctx.vars.insert("reading_time".to_string(), minutes.into());
    Ok(ctx)
}

Cargo.toml

[lib]
crate-type = ["cdylib"]                      # 产出 .wasm

[dependencies]
anycms-ssg-plugin-sdk = "0.1"                # 唯一需要引入的包
extism-pdk = "1"                             # #[plugin_fn] 宏的直接依赖

构建目标必须是 wasm32-unknown-unknown(不是 wasm32-wasip2)。

manifest(<name>.kdl

plugin {
  name "reading-time"
  version "0.1.0"
  api-version "0.2"                 // 当前 API 契约版本,必须为 "0.2"
  author "Liangdi"

  hooks {
    on-template-context priority=100
  }

  contributes {                     // 可选:向模板引擎贡献 filter/function/shortcode
    filter "shout"
  }

  permissions {                     // 权限声明,host 每次调用强制校验
    net "https://api.example.com/**"
    fs-read "./data/**"
    fs-write "./generated/**"
  }

  config {                          // 默认值,可被 site.kdl 覆盖
    wpm 200
  }
}

Host Functions(权限化)

Host 函数所需权限说明
host::get_config()当前 SiteConfig 视图
host::get_plugin_config()本插件合并后的配置
host::get_page(url)内容库中的页面(Option)
host::get_taxonomy(name)指定分类法的 term 列表
host::now()当前 unix 时间戳
host::get_state / set_state插件私有跨 hook 键值状态
host::log(level, msg)日志
host::http_get(url)netHTTP GET(host 精确匹配 + path 前缀)
host::http_post(url, body)netHTTP POST
host::read_file(path)fs-read读站点根下文件(拒绝 .. 穿越)
host::write_output(path, bytes)fs-write写输出目录

未声明权限的插件只能观察 / 转换 host 交给它的数据,无法触达文件系统或网络。host 用 extism::Plugin::new 加载插件且不传递 Manifest / allowed_pathsWASI 文件系统访问完全禁用,所有 FS / 网络访问都收敛到受权限保护的 host function。

Strict 模式

设置环境变量 ANYCMS_PLUGIN_STRICT=1(任何非空非 "0" 值)后,任何插件的 hook 调度错误会中止整个构建(而非默认的 log + 跳过)。适用于 CI / 严格场景。

示例插件

examples/plugins/ 下有 14 个端到端可运行的示例:

示例Hook / 贡献演示
reading-timeon_template_context注入 reading_time 模板变量
site-titleon_template_context + host fn配置往返(config → wasm → 模板)
uppercasifyon_markdown渲染前改写原始 Markdown 源
virtual-pageon_content_discover合成虚拟页面
state-demoon_content_discover + on_template_context插件私有跨 hook 状态
taxonomy-demoon_template_context分类法数据访问
now-demoon_template_contexthost::now() 时间戳
body-injecton_body_injectInject 域:</body> 前注入
head-assetson_head_injectInject 域:<head> 资源注入
http-post-demoon_build_start + on_template_contexthost::http_post 网络访问
config-demoon_template_contextmanifest 配置被 site.kdl 覆盖
shoutcontribute filter贡献 MiniJinja filter(`{{ x
multi-filtercontribute filterfilter_args多参 filter(`{{ s
badgecontribute shortcode插件声明内联 shortcode({{< badge >}}

官方插件(Tier 1)

plugins/ 下提供 5 个开箱即用的官方插件(crate 名 anycms-plugin-*),只增强 host 内置 provider,不替换渲染管线:

插件Hook作用
seo-suiteon_seoOpenGraph / Twitter card / canonical / description 回退 / 去重 JSON-LD
sitemap-robotson_sitemap按 URL 深度设 changefreq/priority、可选 draft/noindex 过滤、生成伴随 robots.txt
search-indexon_search_document清洗正文 HTML、生成智能摘要与语言、设置 boost、附带分类法/日期字段
feed-suiteon_feed_entry增强每条 feed 条目(摘要、正文、作者、分类、URL)
responsive-imageson_markdown_html按命名约定为 <img> 加 srcset/sizes/lazy(仅改写 HTML 标记)

每个插件的 config {} 默认值都可在 site.kdlplugins {} 块中覆盖。完整开发指南见 docs/plugins.md

AnyCMS 构建 在 GitHub 上编辑 ↗