# AnyCMS SSG

> **English** | [English](./README.en.md)

> 一个用 Rust 编写的、**插件驱动**的静态网站生成器（Static Site Generator）。
> 输入：Markdown + KDL 配置 + 主题；输出：可直接部署的静态 HTML 站点。

AnyCMS SSG 是 anycms 生态中的静态站点生成器。与 Hugo / Zola 等「常规 SSG」的根本差异在于：**WASM 插件扩展机制（基于 [extism](https://extism.org/)）+ 插件/主题市场**。插件不是附加功能，而是贯穿整个构建管线的主轴——内容发现、Markdown 渲染、模板上下文、SEO、sitemap、feed、资源处理每一个阶段都可被插件 hook。

- **模板引擎**：[MiniJinja](https://github.com/mitsuhiko/minijinja)（Jinja2 的 Rust 实现）
- **Markdown**：[pulldown-cmark](https://github.com/pulldown-cmark/pulldown-cmark)（GFM 扩展）
- **样式**：[grass](https://github.com/kaj/grass)（纯 Rust Sass 编译 + 压缩）
- **HTML 压缩**：[minify-html](https://github.com/wilsonzlin/minify-html)
- **TypeScript**：[oxc](https://github.com/oxc-project/oxc)（纯 Rust 转译 + minify，无外部依赖、不做类型检查）
- **代码高亮**：[syntect](https://github.com/trishume/syntect)（Sublime 语法）
- **响应式图像**：[image](https://github.com/image-rs/image) + `sha2`（srcset 生成 + 内容哈希缓存）
- **配置**：站点/主题用 [KDL](https://kdl.dev/)，front matter 兼容 TOML / YAML
- **插件运行时**：[extism](https://extism.org/) wasm 沙箱（host SDK + PDK）
- **开发服务器**：axum + notify + WebSocket 实时热重载

---

## 目录

- [特性一览](#特性一览)
- [快速开始](#快速开始)
- [站点结构](#站点结构)
- [配置（site.kdl）](#配置sitekdl)
- [内容模型](#内容模型)
- [模板](#模板)
- [资源管线（Sass / TS / 图像 / 压缩）](#资源管线sass--ts--图像--压缩)
- [主题](#主题)
- [插件系统](#插件系统)
- [官方插件（Tier 1）](#官方插件tier-1)
- [多语言](#多语言)
- [部署（deploy）](#部署deploy)
- [内容编辑后台（editor）](#内容编辑后台editor)
- [分发：静态注册中心](#分发静态注册中心)
- [CLI 命令参考](#cli-命令参考)
- [Workspace 架构](#workspace-架构)
- [开发](#开发)
- [路线图与状态](#路线图与状态)
- [许可证](#许可证)

---

## 特性一览

**核心 SSG 能力（对标 Hugo / Zola）**

- ✅ Markdown → HTML 渲染（GFM、表格、任务列表、脚注、**数学公式（KaTeX 自动注入）**等扩展）
- ✅ Front matter：`+++` TOML / `---` YAML 双格式
- ✅ 分类法（Taxonomies）：tags / categories 等自定义分类 + 分类页
- ✅ 分页（Pagination）
- ✅ 订阅源：RSS (`rss.xml`) / Atom (`atom.xml`)
- ✅ `sitemap.xml` + `robots.txt`
- ✅ 代码高亮（syntect，可主题化，class-based）
- ✅ Shortcodes（Markdown 内嵌可复用片段，插件也可声明自定义 shortcode）
- ✅ 目录（TOC）+ 摘要（`<!-- more -->` 分割）
- ✅ 内容互链（相对链接解析）
- ✅ 草稿（draft）/ 永久链接规则
- ✅ 静态资源原样拷贝（`static/`）
- ✅ 声明式导航菜单（`menu {}` 块，支持 per-language 覆盖）
- ✅ 模板内置全局函数与过滤器（`now` / `get_url` / `get_hash` / `trans` / `get_taxonomy` / `get_image_metadata` / `load_data`，`base64` / `regex_replace` / `markdown` / `slugify`（中文→拼音）/ `date` / `filesize` 等）
- ✅ 链接检查器（`anycms check`：内部链接 + `#fragment` 锚点 + 可选外链 HTTP 校验）
- ✅ 搜索索引多格式（json / fuse_json / fuse_javascript / elasticlunr_json）

**资源管线**

- ✅ Sass → CSS 编译 + 压缩（grass）
- ✅ HTML minify（minify-html）
- ✅ TypeScript / TSX → JS 转译 + minify（oxc，仅转译，不做 bundle / 类型检查）
- ✅ 响应式图像：基于命名约定的 srcset/sizes/lazy 增强（官方插件，内容哈希缓存）
- ✅ 图像处理管线：EXIF 方向自动矫正 + 输出格式转换（含可选 AVIF，纯 Rust 编码器）
- ✅ 主题 overlay：site 模板/静态/sass 覆盖主题同名文件（site > theme > 内置）

**插件生态（核心差异化）**

- ✅ 第三方可写的 WASM 插件（编译到 `wasm32-unknown-unknown`）
- ✅ **10 个 Layer-1 管线 hook**（`on_markdown` / `on_template_context` / `on_asset` …）
- ✅ **8 个 Layer-2 业务域 hook，覆盖 5 个域**（SEO / Sitemap / Search / Feed / Head·Body Inject），含 `on_feed` / `on_search_index` 整站聚合 hook
- ✅ **贡献机制（contribute）**：插件向模板引擎注册 `filter`（含多参 `filter_args`）/ `global_function` / `shortcode`
- ✅ **权限化 Host Functions**：`http_get` / `http_post` / `read_file` / `write_output` 等，按 manifest 声明的权限强制放行
- ✅ 插件配置：manifest 默认值可被 `site.kdl` 的 `plugins {}` 块覆盖
- ✅ **插件沙箱限额**：`fuel`（指令）/ `memory_pages`（内存）/ 墙钟 `timeout` 三件套，`sandbox {}` 全局默认 + `plugins.<name>` 每插件覆盖
- ✅ 插件/主题分发：本地路径、git URL、静态注册中心

**多语言**

- ✅ 文章多语言（文件名后缀 `page.fr.md`，Zola 模式）
- ✅ per-language URL、翻译关联、hreflang、per-language feeds/sitemap、per-language 导航菜单

**部署**

- ✅ `anycms deploy` 内置部署（`git` GitHub Pages 风格推送 / `ssh-rsync` / `s3` 通用对象存储），作为一等命令而非插件（可 shell 出系统 `git`/`rsync`，`s3` 走原生 AWS SDK，需 `--features s3`）

**工具链**

- ✅ 开发服务器实时热重载（WebSocket，默认端口 1111，被占用自动递增）
- ✅ `anycms plugin new/build/list/check/install/update/enable/disable/package`
- ✅ `anycms theme install/list/uninstall/package`
- ✅ 独立的 `anycms-registry build/serve`（静态注册中心）
- ✅ **内容编辑后台**（`anycms admin`：登录 + CodeMirror 编辑 + 实时分屏预览 + 草稿/版本 + 发布闭环）+ **MCP 端点**（让 AI agent 直接写内容/发布，见 [MCP 端点](#mcp-端点)）

---

## 快速开始

### 前置要求

- **Rust 1.85+**（edition 2024，stable 工具链）
- 编写插件还需添加 wasm 编译目标：`rustup target add wasm32-unknown-unknown`

### 安装

```sh
git clone <repo-url> anycms-ssg
cd anycms-ssg
cargo install --path .        # 安装 `anycms` 二进制到 ~/.cargo/bin
```

> 也可直接用 `cargo run --` 代替已安装的 `anycms`。

### 三步生成你的第一个站点

```sh
anycms init my-site           # 生成站点骨架（site.kdl + content + templates + sass + ts）
cd my-site
anycms build                  # → public/（渲染 Markdown、编译 Sass、转译 TS、压缩 HTML）
anycms serve                  # → http://127.0.0.1:1111 实时热重载
```

编辑 `content/` 下的 Markdown，保存后浏览器自动刷新。

### 使用主题

```sh
anycms init my-site
cd my-site
anycms theme install ../themes/ink      # 安装内置主题到 ./themes/ink
# 在 site.kdl 中设置 theme "ink"，并删除 site 自带的 templates/ 和 sass/ 让主题生效：
rm -rf templates sass
anycms build && anycms serve
```

> **overlay 优先级**：site 的 `templates/*.html` 与 `sass/main.scss` 会**覆盖**主题同名文件。
> 全新 `init` 会同时生成这两者，要让已安装主题接管布局与样式，需先删除它们（见 [主题](#主题)）。

### 安装一个插件

```sh
# 从内置示例构建并安装一个插件
cd examples/plugins/reading-time
anycms plugin build --install ../../sites/demo-ink --release

# 构建站点，插件自动加载
anycms build --root ../../sites/demo-ink
```

---

## 站点结构

一个 `anycms` 站点的标准目录布局：

```
my-site/
├── site.kdl              # 站点配置（KDL）
├── content/              # Markdown 内容
│   ├── _index.md         # 首页内容
│   ├── _index.fr.md      # 首页法语翻译
│   └── blog/
│       ├── _index.md     # blog section 列表页
│       └── hello-world.md
├── templates/            # MiniJinja 模板（覆盖主题同名模板）
│   ├── base.html
│   ├── index.html
│   └── page.html
├── sass/                 # Sass 源（编译为 public/main.css）
│   └── main.scss
├── ts/                   # TypeScript 源（转译为 public/main.js）
│   └── main.ts
├── static/               # 原样拷贝到 public/
├── themes/               # 已安装主题（overlay 来源）
│   └── ink/
├── plugins/              # 已安装插件（<name>.wasm + <name>.kdl）
│   └── reading-time.{wasm,kdl}
└── public/               # 构建输出（生成物，勿提交）
```

---

## 配置（site.kdl）

站点与主题配置使用 **KDL**。`anycms init` 生成的最小配置：

```kdl
title "My AnyCMS Site"
base_url "https://example.com"
default_language "en"
output_dir "public"

taxonomies {
    taxonomy name="tags"
}

languages {
    language code="fr"
}

theme "ink"            // 启用主题 overlay（可选）

// 可选：声明式导航菜单（支持 per-language 覆盖）
menu {
    item "Home" url="/"
    item "Docs" url="/docs/" weight=10
    lang "fr" {                    // 某语言的整段覆盖
        item "Accueil" url="/fr/"
    }
}

// 插件级配置覆盖：覆盖各插件 manifest 中的 config {} 默认值（site 胜出）
plugins {
    plugin "reading-time" {
        wpm 250
    }
}

// 插件沙箱限额（P2-2）：WASM 插件的 fuel/内存/超时上限，可被 plugins.<name> 单独覆盖
sandbox {
    fuel 10000000        // 每个 hook 调用的 Wasmtime 指令上限（默认 10_000_000）
    memory_pages 512     // 线性内存页上限，1 页 = 64 KiB（默认 512 ≈ 32 MiB）
    timeout 30           // 每次 hook 调用的墙钟超时秒数，0 = 禁用（默认 30）
}
```

| 字段 | 说明 |
|---|---|
| `title` / `base_url` | 站点标题与基准 URL（用于绝对链接、feeds、sitemap） |
| `default_language` | 默认语言代码 |
| `output_dir` | 构建输出目录（默认 `public`） |
| `theme` | 启用的主题名（对应 `themes/<name>/`） |
| `taxonomies` | 自定义分类法（`tags` / `categories` …） |
| `languages` | 额外支持的语言代码 |
| `menu` | 声明式导航菜单；裸 `item` 归入默认语言，`lang "<code>" {}` 子块整段替换该语言菜单 |
| `params` | 自由参数表，主题默认值会被合并进来，模板中通过 `config.params.<key>` 读取 |
| `plugins` | 各插件的配置覆盖（递归合并，site 覆盖 manifest 默认值） |
| `image` | 图像处理管线（EXIF 方向矫正、输出格式；见 [资源管线](#资源管线sass--ts--图像--压缩)） |
| `check` | 链接检查选项（`external_links`，默认关闭）；见 [CLI check](#cli-命令参考) |
| `search` | 搜索索引格式与字段裁剪（json / fuse_json / fuse_javascript / elasticlunr_json） |
| `deploy` | 部署目标声明（见 [部署](#部署deploy)） |
| `sandbox` | WASM 插件沙箱资源限额（`fuel` / `memory_pages` / `timeout`，可被 `plugins.<name>` 同名键覆盖） |

### 搜索索引（`search {}`）

控制内置搜索索引（`DefaultSearch` provider）的输出格式与字段裁剪。缺省该块时使用默认值（`json` 格式、含 title + content）。插件仍可通过 `on_search_index` 整站聚合 hook 接管索引输出（接管后 host 跳过内置输出）。

```kdl
search {
    format "fuse_json"          // 默认 json；可选 fuse_json / fuse_javascript / elasticlunr_json（大小写不敏感）
    include_title       #true   // 默认 true
    include_content     #true   // 默认 true
    include_description #false  // 默认 false
    include_date        #false  // 默认 false
    include_path        #false  // 默认 false
    truncate_content_length 1000  // 可选；正文中文字符截断长度
}
```

- `format`：`json` 输出单一 `search.json`（语言无关）；`fuse_json` / `fuse_javascript` / `elasticlunr_json` 按语言分文件 `search_index.<lang>.json` / `.js`（`fuse_javascript` 包裹为 `window.searchIndex = <json>;`，便于 `<script>` 直接引入）。
- `elasticlunr_json` 需启用 cargo 特性 `cargo build --features elasticlunr`（默认关闭）；未启用时选择该格式会返回明确错误（提示重新编译），而非静默生成错误索引。
- 输出由 `SearchProvider` trait + 内置 `DefaultSearch` 支撑（与 sitemap/feed 同一 provider 抽象缝）。

---

## 内容模型

- **Page**：单个 Markdown 文件，对应一个 URL。
- **Section**：一个目录，`_index.md` 是该 section 的列表页。
- **Front matter**：正文开头的元数据，支持 `+++ … +++`（TOML）或 `--- … ---`（YAML）。

```markdown
+++
title = "Hello World"
date = 2025-06-01
draft = false
slug = "hello"
template = "page.html"
[taxonomies]
tags = ["rust", "ssg"]
+++

正文内容，支持 `<!-- more -->` 分割摘要。

<!-- more -->

摘要之后的内容。
```

- **草稿**：`draft = true` 的页面在 `build` 时被排除。
- **分类法页**：由 `taxonomies` 配置自动生成（如 `/tags/rust/`）。
- **代码高亮**：用 `` ```rust `` 围栏，输出 class-based span（配合 `static/highlight.css`）。
- **任务清单**：`- [ ]` / `- [x]` 渲染为 checkbox（GFM task list）。
- **数学公式**：`$...$` 行内、`$$...$$` 块级，输出带 `class="math"` 的 span；含公式的页面会**自动注入** KaTeX CSS/JS 到 `head_inject`（浏览器端渲染，无需手动配置、无 nodejs/native 依赖）。

---

## 模板

模板引擎是 **MiniJinja**（Jinja2 语法）。`{{ … }}` 输出、`{% … %}` 控制流、`{% extends %}` / `{% block %}` 继承。

**模板解析 overlay（三级回退）**：site → theme → 内置技术模板（sitemap/rss/robots 可被覆盖）。

**关键契约变量**：`base.html` 必须用 `| safe` 渲染三个接缝变量，插件注入和 SEO 才有落点：

```html
<head>
  {{ seo_head | safe }}      {# SEO meta/canonical/OG/JSON-LD，由 host 生成 #}
  {{ head_inject | safe }}    {# 插件 on_head_inject 注入的标签 + 含数学公式时自动注入的 KaTeX #}
</head>
<body>
  {{ body_inject | safe }}    {# 插件 on_body_inject，在 </body> 之前 #}
</body>
```

**导航菜单**：通过 `config.menu` 渲染。当前语言无菜单时回退到默认语言：

```html
{% set ml = current_language | default(config.default_language) %}
{% set items = config.menu[ml] | default(config.menu[config.default_language]) %}
{% for item in items %}<a href="{{ item.url | safe }}">{{ item.label }}</a>{% endfor %}
```

> ⚠️ MiniJinja 会把生成 URL 中的 `/` 转义成 `&#x2f;`，因此所有生成的链接在 `href` / `src` 中须标记 `| safe`。

**内置全局函数与过滤器**：除插件贡献的 dispatcher 外，host 还在模板环境上注册了一批第一方全局函数与过滤器（对标 Zola 的 Tera 全局函数 / 过滤器），不加载插件也始终可用：

- 全局函数：
  - `now(format?)` — 当前 UTC 时间字符串。无参时返回 RFC3339（如 `2026-06-20T13:45:00Z`）；传参时使用 `time` crate 的格式描述语言（**非** strftime），例如 `now("[year]-[month]-[day]")`。
  - `get_url(path, hash=true?)` — 工程相对路径（如 `/static/app.css`）的输出 URL；`hash=true` 时追加 `?v=<短哈希>` 做缓存击穿（默认 `false`）。返回的 URL 在 `href`/`src` 中需 `| safe`。
  - `get_hash(path)` — 文件 SHA-256 的前 10 位十六进制（文件不可读时返回空串）。
  - `trans(key, lang?)` — 从 `config.params.translations` 查翻译，缺失时回退到 `key` 本身（Zola 行为）。`lang` 缺省取 `default_language`。
  - `get_taxonomy(kind, lang?)` — 序列化指定分类法（如 `tags`）；未声明时返回 `undefined`（`lang` 目前接受但不过滤）。
  - `get_image_metadata(path)` — `{ width, height, format }`；文件缺失/无法解码时返回 `undefined`。
  - `load_data(path, required=true?)` — 加载并解析数据文件：本地工程路径按扩展名分派（`.json` / `.toml` / `.yaml` / `.yml` / `.csv`，CSV → 行对象数组）；`http(s)://` 远程 URL（15s 超时、10 MiB 上限）。`required=false` + 缺失/失败 → `null`；默认 `required=true` + 失败 → 构建错误。对标 Zola 的 `load_data`。
- 过滤器：
  - `base64_encode` / `base64_decode` — 标准 base64（带 padding）编解码；非法输入解码为空串，不会 panic。
  - `regex_replace(pattern, replacement)` — 正则替换所有匹配（如 `{{ s | regex_replace("\\d+", "N") }}`）。
  - `markdown` — 将 Markdown 字符串渲染为 HTML（无 summary/TOC）；输出为原始 HTML，需链 `| safe` 才不被转义。
  - `slugify(ascii_only=true?)` — URL 友好的 slug。默认 `ascii_only=true` 经 deunicode 把非拉丁（CJK/重音）转拉丁近似（`"你好"` → `"ni-hao"`）；`false` 保留原文脚本（`"你好"` → `"你好"`）。
  - `date(format?)` — 格式化 ISO 日期/时间字符串（如 front-matter `date`），用 `time` crate 的格式描述（默认 `[year]-[month]-[day]`）；不可解析时原样返回。
  - `filesize` — 字节数 → 人类可读（`1536` → `"1.5 KiB"`），接受数字或数字字符串。

> 约定：缺失/无法解析的输入返回输入本身或 `undefined`/空串，绝不中断构建。

---

## 资源管线（Sass / TS / 图像 / 压缩）

构建时自动处理：

| 输入 | 处理 | 输出 |
|---|---|---|
| `sass/main.scss` | grass 编译 + 压缩 | `public/main.css` |
| `ts/main.ts` / `*.tsx` | oxc 转译 + minify（不做类型检查/bundle） | `public/*.js` |
| 文章内 `<img>` | 响应式增强（官方插件 [responsive-images](#官方插件tier-1)：srcset/sizes/lazy/decoding，仅改写 HTML 标记） | 增强后的 `public/**/*.html` |
| HTML 输出 | minify-html 压缩 | 压缩后的 `public/**/*.html` |
| `static/**` | 原样拷贝 | `public/**` |

主题的 `sass/` 会被加入 Sass include path，主题 `static/` 与 site 的 `static/` 做 overlay 合并（site 胜出）。

### 图像处理管线（EXIF 方向 + 输出格式）

`site.kdl` 中的 `image {}` 块控制图像处理管线（默认 `enabled #false`，需显式开启）：

```kdl
image {
    enabled #true                 // 默认 false
    widths "640,1024,1920"        // 默认 [640, 1024, 1920]
    quality 80                    // 默认 80（JPEG/WebP/AVIF）
    auto_orient #true             // 默认 true — 读 EXIF Orientation(1–8) 在缩放前旋转
    format "auto"                 // 默认 auto；可选 jpeg/png/webp/avif（大小写不敏感）
}
```

- **EXIF 自动方向矫正**（`auto_orient`，默认 `true`）：读取源图的 EXIF `Orientation` 标签（1–8），在尺寸计算与变体生成之前旋转成正立朝向，确保手机拍摄的图片不再侧躺/倒置。EXIF 读取失败时回退到不旋转（orientation 1）并打印警告，不中断构建。
- **输出格式**（`format`，默认 `auto`）：`auto` 保留源格式；也可统一转码为 `jpeg` / `png` / `webp` / `avif`。
- **AVIF 输出**：选择 `avif` 需启用 cargo 特性 —— `cargo build --features avif`（默认关闭，保持默认构建精简）。编码器为纯 Rust 的 `ravif`（经 `image` crate 的 `avif` 特性引入），无需 C 工具链，可在任意平台编译。**注意：仅支持 AVIF 编码，不支持 AVIF 解码**（解码需要 C 工具链的 dav1d，故有意排除）。启用后 release 二进制体积约增加 ~1.05 MB。

---

## 主题

主题是可分发的 bundle：`theme.kdl` manifest + `templates/`（必需）+ 可选 `static/` / `sass/`。三个官方主题随仓库发布（见 [`themes/README.md`](./themes/README.md)）：

| 主题 | 定位 | 设计 | 展示能力 |
|---|---|---|---|
| [`ink`](./themes/ink) | 极简博客（**参考默认主题**） | 衬线、单栏、纸墨感 | 全套模板、feeds、sitemap、高亮、params 主题化 |
| [`manual`](./themes/manual) | 文档 / 手册 | 无衬线、顶部搜索、页内 TOC | section 导航、客户端 TOC、search.json |
| [`folio`](./themes/folio) | 作品集 / 视觉 | 粗体展示字、响应式画廊网格 | landing、项目卡片、`columns` 参数 |

```kdl
theme {
  name "ink"
  version "0.1.0"
  engine-version "0.1"        // 必需：必须与 host engine 的 major.minor 匹配
  author "AnyCMS"
  description "Minimal blog theme"
  params {
    accent default="#333"
    dark default="auto" enum="auto,on,off"
  }
}
```

- **engine-version 校验**：安装时与构建时都会检查，主题声明的 `engine-version` 必须与 host engine 共享 `major.minor`，否则拒绝。
- **params 合并**：主题 `params` 声明默认值，site 的 `params {}` 覆盖之，模板中读 `config.params.<key>`。
- 详见 [`docs/themes.md`](./docs/themes.md)。

---

## 插件系统

插件是 WebAssembly 模块，构建时由 host 加载并按 **priority 顺序**（数字越小越先执行）调度。

### 两层扩展点

- **Layer 1 — 10 个通用管线 hook**：`on_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`。

### 写一个插件

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

const WPM: u64 = 200;

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

`Cargo.toml`：

```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`）

```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 时间戳 i64（插件内 `std::time` 受限） |
| `host::get_state(key)` / `host::set_state(key, value)` | 无 | 插件私有跨 hook 键值状态 |
| `host::log(level, msg)` | 无 | 日志 |
| `host::http_get(url)` | `net` | HTTP GET（host 精确匹配 + path 前缀） |
| `host::http_post(url, body)` | `net` | HTTP POST |
| `host::read_file(path)` | `fs-read` | 读站点根下文件（拒绝 `..` 穿越） |
| `host::write_output(path, bytes)` | `fs-write` | 写输出目录 |

未声明权限的插件只能观察/转换 host 交给它的数据，无法触达文件系统或网络——沙箱安全由 manifest 权限 + host 强制放行保证。host 用 `extism::Plugin::new` 加载插件且不传递 Manifest / `allowed_paths`，WASI 文件系统访问完全禁用，所有 FS/网络访问都收敛到受权限保护的 host function。

> **Strict 模式**：设置环境变量 `ANYCMS_PLUGIN_STRICT=1`（任何非空非 `"0"` 值）后，任何插件的 hook 调度错误会**中止整个构建**（而非默认的 log + 跳过该插件）。适用于 CI / 严格场景。

### 示例插件

[`examples/plugins/`](./examples/plugins/) 下有 **14 个端到端可运行的示例**：

| 示例 | Hook / 贡献 | 演示 |
|---|---|---|
| `reading-time` | `on_template_context` | 注入 `reading_time` 模板变量 |
| `site-title` | `on_template_context` + host fn | 配置往返（config → wasm → 模板） |
| `uppercasify` | `on_markdown` | 渲染前改写原始 Markdown 源 |
| `virtual-page` | `on_content_discover` | 合成虚拟页面 |
| `state-demo` | `on_content_discover` + `on_template_context` | 插件私有跨 hook 状态 |
| `taxonomy-demo` | `on_template_context` | 分类法数据访问 |
| `now-demo` | `on_template_context` | `host::now()` 时间戳 |
| `body-inject` | `on_body_inject` | Layer-2 Inject 域：`</body>` 前注入 |
| `head-assets` | `on_head_inject` | Layer-2 Inject 域：`<head>` 资源注入 |
| `http-post-demo` | `on_build_start` + `on_template_context` | `host::http_post` 网络访问 |
| `config-demo` | `on_template_context` | manifest 配置被 site.kdl 覆盖 |
| `shout` | contribute `filter` | 贡献 MiniJinja filter（`{{ x | shout }}`） |
| `multi-filter` | contribute `filter`（`filter_args`） | 多参 filter（`{{ s | repeat(n) }}`） |
| `badge` | contribute `shortcode` | 插件声明的内联 shortcode（`{{< badge >}}`） |

完整插件开发指南见 [`docs/plugins.md`](./docs/plugins.md)，v0.1 契约见 [`docs/design/plugin-api-v0.1.md`](./docs/design/plugin-api-v0.1.md)，v0.2 增量差距分析见 [`docs/design/plugin-api-v0.2-gaps.md`](./docs/design/plugin-api-v0.2-gaps.md)。

---

## 官方插件（Tier 1）

[`plugins/`](./plugins/) 下提供 5 个开箱即用的官方插件，覆盖最常见的「增强」场景（crate 名 `anycms-plugin-*`，author `AnyCMS`）。它们只增强 host 已有的内置 provider，不替换渲染管线：

| 插件 | Hook | 作用 |
|---|---|---|
| [`seo-suite`](./plugins/seo-suite) | `on_seo` | 配置驱动的 SEO 增强：OpenGraph / Twitter card / canonical / description 回退 / 去重 JSON-LD（WebSite、Organization、BreadcrumbList、Article） |
| [`sitemap-robots`](./plugins/sitemap-robots) | `on_sitemap` | 按 URL 深度设 changefreq/priority、可选 draft/noindex 过滤、lastmod 回填，并生成伴随的 `robots.txt` |
| [`search-index`](./plugins/search-index) | `on_search_document` | 清洗正文 HTML、生成智能摘要与语言、设置 boost、附带分类法/日期/类型字段、应用排除规则 |
| [`feed-suite`](./plugins/feed-suite) | `on_feed_entry` | 增强每条 feed 条目（摘要、正文、作者、分类、URL） |
| [`responsive-images`](./plugins/responsive-images) | `on_markdown_html` | 按命名约定为 `<img>` 添加 srcset/sizes/lazy/decoding（仅改写 HTML 标记，不重新编码图像） |

每个插件的 `config {}` 默认值都可在 `site.kdl` 的 `plugins {}` 块中覆盖。详见各插件目录的 README。

---

## 多语言

文章多语言采用**文件名后缀**组织（Zola 模式，最少侵入）：`hello-world.md`（默认语言）+ `hello-world.fr.md`（法语）。翻译自动关联，生成 per-language URL、hreflang 互链、per-language feeds/sitemap。`languages { language code="fr" }` 声明支持的语言；`menu {}` 的 `lang "<code>" {}` 子块可为每种语言定制导航。

> 注：本项目的 i18n 指**内容多语言**，不包含工具自身 UI 的系统本地化。

---

## 部署（deploy）

`anycms deploy` 把 `output_dir`（默认 `public/`）发布到配置好的目标。它是 anycms 的**一等命令而非插件**——因此可以 shell 出系统的 `git` 与 `rsync`，或直接链接 AWS SDK 走 S3（沙箱插件无法做到）。内置三个 provider：`git`（GitHub Pages 风格的 `git push`）、`ssh-rsync`（`rsync -avz --delete` over SSH），以及 `s3`（原生镜像同步到任意 S3 兼容存储——MinIO / Cloudflare R2 / B2 / OSS / AWS S3，无需 `aws` CLI，需 `cargo build --features s3`）。

```kdl
deploy {
    target "gh-pages" {
        provider "git"
        repo     "git@github.com:user/user.github.io.git"
        branch   "gh-pages"
    }
    target "prod" {
        provider  "ssh-rsync"
        dest      "user@host:/var/www/site/"
        delete    #true
    }
}
```

```sh
anycms build
anycms deploy gh-pages            # 发布指定目标
anycms deploy --build             # 先 build 再发布
anycms deploy --dry-run           # 仅打印将执行的命令，不产生副作用
```

详见 [`docs/deploy.md`](./docs/deploy.md)。

---

## 内容编辑后台（editor）

`anycms admin` 是一个**独立的内容编辑服务**（默认端口 3333），提供 WordPress 式管理后台：作者登录后在浏览器里编辑 Markdown、实时预览、保存草稿、一键发布。它**不是** `serve` 的一部分，也不复用 devtools 的无认证路由——是一个带认证的独立进程；静态站点本身仍纯静态部署到 CDN。

```sh
anycms admin --root my-site --create-admin admin:s3cret
# 首次引导 admin 用户并启动；浏览器开 http://127.0.0.1:3333/__anycms_admin/
```

- **认证**：argon2 密码哈希 + tower-sessions cookie 会话；`--create-admin USER:PASS` 引导首个 admin（幂等）；users 表为空且未给该 flag 时**拒绝启动**（绝不暴露无认证后台）。
- **混合存储**：草稿 / 用户 / 媒体元数据 / 版本历史存 SQLite（`<root>/.anycms/editor.sqlite`，已 gitignore）；**发布**时才落盘 `content/*.md`（草稿永不落盘、不进 build）。
- **编辑器**：CodeMirror 6 + 分屏实时预览（后端 `markdown::render`）；front matter 表单（title / draft / date / slug / tags …）。
- **版本历史**：每次保存追加快照，可一键恢复。
- **媒体上传**：拖入即上传到 `static/uploads/`，自动防路径穿越，一键复制 Markdown 引用。
- **发布闭环**：草稿 → 原子写 `content/*.md` → `anycms build` → 可选 `anycms deploy`；build 失败则不部署（错误信息会点名）。
- **数据**：每个站点一个独立 SQLite（单租户）；`.anycms/` 目录已 gitignore，与 devtools 的预览覆盖、deploy 的本地仓库同住一处。

> editor 由 `anycms-ssg-editor` crate 实现，依赖 content / markdown / site / deploy；前端是独立 React SPA（CodeMirror 6 + shadcn/ui），编译期内嵌（`anycms-spa` 的 `spa!` 宏）。

### MCP 端点

editor 还内置一个 **MCP**（Model Context Protocol）端点，让外部 AI agent 直接编写内容、发布、构建、部署——无需走浏览器的 cookie 登录。基于 [`anycms-mcp`](https://github.com/ai-station/anycms-mcp) 0.3（axum 传输，profile 过滤）。

| 路径 | 说明 |
|---|---|
| `/mcp` | MCP streamable HTTP 端点（暴露所有工具） |
| `/mcp-profiles` | profile 管理 REST（`/list` `/info` `/{id}` …，`/info` 列出工具） |

**启用**（二选一；`site.kdl` 优先，都为空则**不挂载** `/mcp`，fail-closed）：

```kdl
# site.kdl
mcp {
    token "a-long-random-secret"
}
```

或环境变量：`ANYCMS_MCP_TOKEN=secret anycms admin --root my-site`

**鉴权**：每个请求带 `Authorization: Bearer <token>`（缺失/错误 → 401；端点未挂载 → 404）。

**8 个工具**（复用 editor 现有业务逻辑，不改 HTTP API）：
`list_content` · `get_draft` · `upsert_draft` · `list_draft_revisions` · `publish` · `build_site` · `deploy_site` · `render_preview`

```sh
# 探测：列出全部工具（带 token）
curl http://127.0.0.1:3333/mcp-profiles/info -H "Authorization: Bearer <token>"
```

> mutate 工具（publish / build / deploy / upsert）以 `mcp-agent` 系统身份执行（无 session）；启动时幂等插入一行 backing `users`（空密码、永远登不进）以满足 `drafts.updated_by` 等 FK 约束。


---

## 分发：静态注册中心

插件与主题可从**静态注册中心**分发——一个带 `index.json` 索引的目录，托管在任何静态服务上（GitHub Pages、S3、本地目录）。无需动态服务器、鉴权或数据库。

```sh
# 维护者：把制品放入版本化目录后重建索引
anycms-registry build /path/to/registry-root     # 扫描 + 打包 theme tar.gz + 算 sha256 + 写 index.json
anycms-registry serve /path/to/registry-root --port 1111   # 本地静态服务（用于测试）

# 用户：从注册中心安装（latest 或 --version 指定）
anycms plugin install shout --registry https://example.com/registry --root ./my-site
anycms theme   install ink   --registry https://example.com/registry --root ./my-site --version 0.1.0
anycms plugin install shout --registry /srv/registry --root ./my-site   # 本地路径也行
```

每个可下载制品在索引中带 `sha256`，下载时校验。详见 [`docs/registry.md`](./docs/registry.md) 与 [`docs/publishing.md`](./docs/publishing.md)（打包→发布→安装完整流程）。

---

## CLI 命令参考

二进制名 `anycms`（入口 `src/main.rs` → `anycms-ssg-cli`）。注册中心是独立二进制 `anycms-registry`。

### 站点

```sh
anycms init [PATH] [--theme NAME]   # 生成站点骨架；--theme 额外生成本地主题骨架
anycms build [--root ROOT]          # 构建到 output_dir
anycms serve [--root ROOT] [--port PORT]   # 开发服务器 + 实时热重载（默认 1111，被占用自动递增）
anycms admin [--root ROOT] [--port PORT] [--host HOST] [--create-admin USER:PASS]   # 内容编辑后台（默认 3333；见 [editor](#内容编辑后台editor)）
anycms check [--root ROOT] [--skip-external-links]   # 校验内容与链接，不写输出（错误非零退出，警告不会）
```

### 部署

```sh
anycms deploy [TARGET] [--root ROOT] [--dry-run] [--build]   # 发布到 site.kdl 中声明的目标
```

**`check` 链接检查器**（对标 Zola `check`）按如下顺序校验，错误非零退出、警告不会：

- **内部链接**：标准 Markdown `[..](..)` 与裸 `<a href="...">`，相对（`./x`、`../x`、`foo.md`）与根相对（`/blog/`）链接均解析到 URL 索引；`mailto:`/`tel:`/`ftp:`/`data:` 等非 http(s) scheme 不在检查范围。围栏代码块中的链接被忽略。
- **锚点**：链接中的 `#fragment` 必须指向目标页真实存在的标题 id（slugify + 去重，与渲染器一致）。
- **外链**：仅当 `site.kdl` 中 `check { external_links #true }`（默认关闭）时启用；用 `reqwest` 做 `HEAD`→`GET` 回退校验（2xx/3xx 视为可达），有界并发（8）与 10s 超时。CLI 标志 `--skip-external-links` 强制关闭外链检查（即便配置里开了）。

```sh
```

### 插件管理

```sh
anycms plugin new <NAME> [--dest DIR] [--sdk-path PATH]   # 脚手架新插件 crate
anycms plugin build [--install SITE-ROOT] [--release]      # 构建到 wasm 并（可选）安装
anycms plugin list   [--root ROOT]      # 列出已装插件（解析 .kdl，不加载 wasm）
anycms plugin check  [--root ROOT]      # 诊断：manifest + wasm 导出是否匹配（CI 可用，出错非零退出）
anycms plugin install <SOURCE> [--root ROOT] [--force] [--registry BASE] [--version VER]
anycms plugin update [NAME]   [--root ROOT] [--force]      # 按记录的源重新拉取（NAME 缺省=全部）
anycms plugin uninstall <NAME> [--root ROOT]
anycms plugin enable  <NAME>  [--root ROOT]                # 启用（恢复 .wasm）
anycms plugin disable <NAME>  [--root ROOT]                # 停用（.wasm → .wasm.disabled，构建时跳过）
anycms plugin package [--output FILE] [--no-build] [--force]   # 打包为 <name>-<version>.zip
```

`install` 的 `SOURCE` 自动判别：`http(s)://` / `git@` / `ssh://` 开头 → git 仓库；否则本地目录；配合 `--registry` 则是注册中心包名。

### 主题管理

```sh
anycms theme install <SOURCE> [--root ROOT] [--force] [--registry BASE] [--version VER]
anycms theme list   [--root ROOT]
anycms theme uninstall <NAME> [--root ROOT]
anycms theme package <DIR> [--output FILE] [--force]     # 打包为 <name>-<version>.zip
anycms theme search        # （planned — M7）
anycms theme update        # （planned — M7）
```

### 注册中心（独立二进制 `anycms-registry`）

```sh
anycms-registry build <REGISTRY-ROOT>     # 重建 index.json + theme bundles
anycms-registry serve <REGISTRY-ROOT> --port 1111
```

---

## Workspace 架构

本项目是 Cargo workspace，所有 crate 在 `crates/` 下，统一版本 0.1.0，edition 2024，Rust 1.85+。

依赖方向（高层 → 底层）：`cli → {site, editor}`；`site → {content, markdown, templates, assets, images, plugins, registry, deploy}`；`editor → {content, markdown, site, deploy, config}`；底层 `{config, i18n} → {errors, utils}`。`anycms-ssg-site` 是 build/serve 编排核心，`anycms-ssg-editor` 是内容后台（复用 site 的 build 能力做发布闭环，不复用 devtools 的无认证路由）。

| Crate | 角色 |
|---|---|
| [`anycms-ssg-errors`](./crates/anycms-ssg-errors) | 统一错误类型 / Result |
| [`anycms-ssg-utils`](./crates/anycms-ssg-utils) | fs / slugs / urls / globs / 锚点等工具 |
| [`anycms-ssg-config`](./crates/anycms-ssg-config) | KDL 站点/主题配置解析 + 校验（定义 `ENGINE_VERSION`） |
| [`anycms-ssg-i18n`](./crates/anycms-ssg-i18n) | 语言表 / 多语言内容组织 |
| [`anycms-ssg-content`](./crates/anycms-ssg-content) | front matter、Page/Section、Library、分类法、分页、排序 |
| [`anycms-ssg-markdown`](./crates/anycms-ssg-markdown) | pulldown-cmark 渲染、shortcode、代码高亮 |
| [`anycms-ssg-templates`](./crates/anycms-ssg-templates) | MiniJinja 封装、overlay loader、内置模板、filters |
| [`anycms-ssg-assets`](./crates/anycms-ssg-assets) | Sass 编译 + HTML minify + TS 转译 + static 拷贝 |
| [`anycms-ssg-images`](./crates/anycms-ssg-images) | 响应式图像：srcset 生成 + 内容哈希缓存（image + sha2） |
| [`anycms-ssg-plugin-api`](./crates/anycms-ssg-plugin-api) | **【发布】** host 与插件共享契约（hook/类型/manifest/版本，当前 v0.2），无 extism 依赖 |
| [`anycms-ssg-plugin-sdk`](./crates/anycms-ssg-plugin-sdk) | **【发布】** 第三方 PDK：依赖 extism-pdk + api，提供宏/host_fn/re-export |
| [`anycms-ssg-plugin-sdk-macros`](./crates/anycms-ssg-plugin-sdk-macros) | SDK 过程宏（`#[on_*]` / `contribute!`） |
| [`anycms-ssg-plugins`](./crates/anycms-ssg-plugins) | **【内部】** host 侧：extism 加载、hook 调度、bridge、Host Functions、权限强制 |
| [`anycms-ssg-registry`](./crates/anycms-ssg-registry) | **【内部】** 插件/主题市场 client（安装/更新/卸载/启停）+ 注册中心协议 |
| [`anycms-ssg-registry-server`](./crates/anycms-ssg-registry-server) | **【内部】** 独立 bin `anycms-registry`（静态注册中心 build/serve） |
| [`anycms-ssg-deploy`](./crates/anycms-ssg-deploy) | **【内部】** `anycms deploy` 的 provider 实现（git / ssh-rsync / s3） |
| [`anycms-ssg-site`](./crates/anycms-ssg-site) | 编排层：组装 build / serve 主流程（依赖以上全部） |
| [`anycms-ssg-cli`](./crates/anycms-ssg-cli) | bin 入口：clap 子命令 |
| [`anycms-ssg-devtools`](./crates/anycms-ssg-devtools) | serve 调试面板（切换 theme / 启停 plugin / 可视化 params，仅 localhost） |
| [`anycms-ssg-editor`](./crates/anycms-ssg-editor) | 独立 `anycms admin` 内容后台（登录 + 草稿编辑 + 预览 + 发布闭环；SQLite + argon2 + CodeMirror6） |

> `anycms-ssg-plugin-api` 与 `anycms-ssg-plugin-sdk` 设计为发布到 crates.io 的公共契约；其余为内部 crate。契约一旦发布即视为对第三方的承诺。

---

## 开发

```sh
just dev          # RUST_LOG=debug cargo run
just build        # cargo build
just release      # cargo build --release
just install      # cargo install --path .

# 全量质量门（CI 同款）
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo build --workspace --all-targets
cargo test --workspace
```

构建一个示例插件：

```sh
cd examples/plugins/reading-time
cargo build --target wasm32-unknown-unknown --release
# 或用工具链：
anycms plugin build --install ../../sites/demo-ink --release
```

测试站点位于 [`sites/`](./sites/)（`demo-ink` / `demo-folio` / `demo-manual` / `demo-theme`）。

---

## 路线图与状态

完整路线图见 [`docs/prd/01-roadmap.md`](./docs/prd/01-roadmap.md)，需求分析见 [`docs/prd/00-analysis.md`](./docs/prd/00-analysis.md)。

| 里程碑 | 名称 | 状态 |
|---|---|---|
| M0 | 脚手架（workspace + crate 骨架 + CLI） | ✅ 完成 |
| M1 | 单语言 SSG MVP（init / build / serve） | ✅ 完成 |
| M2 | 常规能力 + provider 抽象缝（tax/分页/feeds/sitemap/高亮/shortcode/sass/minify） | ✅ 完成 |
| M2.5 | 主题与布局（overlay / params 合并 / theme↔plugin 接缝） | ✅ 完成 |
| M3 | 文章多语言（hreflang / per-language URL） | ✅ 完成 |
| M4 | 插件生态（Layer1+2 + 贡献 + 权限化 host fn） | ✅ 完成 |
| M5 | TypeScript（oxc 转译 + minify） | ✅ 完成 |
| M6 | 插件市场 client（安装/更新/卸载/启停 + git 源） | ✅ 基本完成 |
| M7 | Registry Server（静态注册中心完成；动态服务器推迟） | 🚧 部分 |

> **内容编辑后台（editor）**：M7 之外新增的独立能力——`anycms admin` 命令 + `anycms-ssg-editor` crate，提供带认证的内容编辑 / 预览 / 发布后台（见 [内容编辑后台](#内容编辑后台editor)）。已完成登录、草稿编辑、CodeMirror 分屏预览、版本历史、媒体上传、发布闭环。

> v0.2 API 在 v0.1 基础上补齐了整站聚合 hook（`on_feed` / `on_search_index`）、多参贡献 filter、插件声明 shortcode 等能力，差距分析见 [`docs/design/plugin-api-v0.2-gaps.md`](./docs/design/plugin-api-v0.2-gaps.md)。

> **v0.2 对标补齐（Zola 能力差距关闭）**：以下四项在 v0.2 落地 —— ① 模板内置全局函数/过滤器（`now` / `get_url` / `get_hash` / `trans` / `get_taxonomy` / `get_image_metadata` + `base64` / `regex_replace` / `markdown` 过滤器）；② 链接检查器（`anycms check`：内部链接 + `#fragment` 锚点 + 可选外链 HTTP 校验）；③ 搜索索引多格式（json / fuse_json / fuse_javascript / elasticlunr_json，后者需 `--features elasticlunr`）；④ 图像 EXIF 方向自动矫正 + AVIF 输出（AVIF 需 `--features avif`）。

---

## 许可证

待补充（仓库暂未包含 `LICENSE` 文件）。
