Skip to main content

yggdrasil/api/
changelog.rs

1//! 更新日志接口。
2//!
3//! `CHANGELOG.md` 在编译期经 `include_str!` 内嵌进二进制(scratch 运行时镜像
4//! 只有 binary + public/ + uploads/,磁盘上没有该文件;与 migrations、
5//! highlight.css 的内嵌原则一致)。
6//!
7//! 与旧实现(整篇 Markdown → HTML blob → `dangerous_inner_html`)不同,本模块
8//! 将 Keep a Changelog 格式的 Markdown **解析为结构化数据**(版本 → 分类 → 条目),
9//! 供前端按版本卡片 + 分类色标 badge 渲染时间线视图。
10//!
11//! 解析流程(纯函数,进程生命周期内只执行一次,`LazyLock` 缓存):
12//! 1. `changelog_body` 剥去 preamble(`# Changelog` 标题 + Keep a Changelog 说明)
13//! 2. `parse_changelog` 逐行扫描,按 `## `(版本)和 `### `(分类)切分
14//! 3. 每个分类组的条目经 `render_markdown_enhanced` 渲染为 HTML 片段
15//!    (保留 `**bold**` / `` `code` `` / `[link]` 等内联格式 + sanitizer 清理)
16//!
17//! 分类色标映射遵循全站 Catppuccin 双强调色约束(accent 绿 / accent-2 teal),
18//! 不引入第三色——通过字号、字重、透明度区分层级。
19
20// 与 settings 等模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
21#![allow(clippy::unused_unit, deprecated)]
22
23use dioxus::prelude::*;
24
25// ===========================================================================
26// 数据结构(双 target 共享:server 序列化 → WASM 反序列化渲染)
27// ===========================================================================
28
29/// 变更分类。对应 Keep a Changelog 的标准分类 + 本项目扩展的 Internal。
30///
31/// 序列化为小写字符串(`"added"` / `"fixed"` …),前端据此选择 badge CSS 类。
32#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ChangeCategory {
35    Added,
36    Changed,
37    Fixed,
38    Deprecated,
39    Removed,
40    Security,
41    Internal,
42}
43
44impl ChangeCategory {
45    /// 中文标签(badge 显示文本)。
46    pub fn label(&self) -> &'static str {
47        match self {
48            Self::Added => "新增",
49            Self::Changed => "改进",
50            Self::Fixed => "修复",
51            Self::Deprecated => "弃用",
52            Self::Removed => "移除",
53            Self::Security => "安全",
54            Self::Internal => "内部",
55        }
56    }
57
58    /// CSS 修饰类名后缀(用于 `changelog-badge--{css_class}`)。
59    pub fn css_class(&self) -> &'static str {
60        match self {
61            Self::Added => "added",
62            Self::Changed => "changed",
63            Self::Fixed => "fixed",
64            Self::Deprecated => "deprecated",
65            Self::Removed => "removed",
66            Self::Security => "security",
67            Self::Internal => "internal",
68        }
69    }
70
71    /// 从 Markdown `### Header` 文本解析分类。未知分类回退为 Internal。
72    #[cfg(feature = "server")]
73    fn from_name(name: &str) -> Self {
74        match name.trim() {
75            "Added" => Self::Added,
76            "Changed" => Self::Changed,
77            "Fixed" => Self::Fixed,
78            "Deprecated" => Self::Deprecated,
79            "Removed" => Self::Removed,
80            "Security" => Self::Security,
81            "Internal" => Self::Internal,
82            _ => Self::Internal,
83        }
84    }
85}
86
87/// 单个分类组(如 "Added" 下所有条目的渲染 HTML)。
88#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
89pub struct ChangeGroup {
90    /// 分类。
91    pub category: ChangeCategory,
92    /// 该分类下所有条目经 Markdown 渲染后的 HTML 片段(`<ul><li>…</li></ul>`)。
93    pub items_html: String,
94}
95
96/// 单个版本条目。
97#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
98pub struct VersionEntry {
99    /// 版本号(如 `"0.8.3"`)或 `"Unreleased"`。
100    pub version: String,
101    /// 发布日期(ISO 格式 `"2026-08-03"`),Unreleased 版本为 None。
102    pub date: Option<String>,
103    /// 是否为最新正式版(第一个非 Unreleased 的版本)。
104    pub is_latest: bool,
105    /// 版本下不属于任何 `###` 分类的正文 HTML(如 Unreleased 的占位文字)。
106    pub intro_html: String,
107    /// 按分类分组的变更条目。
108    pub groups: Vec<ChangeGroup>,
109}
110
111/// 完整的 changelog 结构化数据。
112#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
113pub struct ChangelogData {
114    /// 版本列表(按 CHANGELOG.md 出现顺序,通常为最新在前)。
115    pub versions: Vec<VersionEntry>,
116}
117
118// ===========================================================================
119// 服务端:解析与渲染
120// ===========================================================================
121
122/// CHANGELOG.md 原文,编译期内嵌。
123#[cfg(feature = "server")]
124const CHANGELOG_MD: &str = include_str!("../../CHANGELOG.md");
125
126/// 解析结果,进程生命周期内只计算一次。
127#[cfg(feature = "server")]
128static CHANGELOG_PARSED: std::sync::LazyLock<ChangelogData> =
129    std::sync::LazyLock::new(|| parse_changelog(CHANGELOG_MD));
130
131/// 剥去 CHANGELOG.md 开头的 `# Changelog` 标题与 Keep a Changelog 说明段,
132/// 从第一个 `## ` 版本段起返回;不符合预期格式时回退全文。
133#[cfg(feature = "server")]
134fn changelog_body(full: &str) -> &str {
135    match full.find("\n## ") {
136        Some(i) => &full[i + 1..],
137        None => full,
138    }
139}
140
141/// 解析过程中的临时版本结构(收集原始 Markdown 文本,稍后统一渲染)。
142#[cfg(feature = "server")]
143struct RawVersion {
144    version: String,
145    date: Option<String>,
146    intro_md: String,
147    categories: Vec<(ChangeCategory, String)>,
148}
149
150/// 将 CHANGELOG.md 全文解析为结构化 `ChangelogData`。
151///
152/// 逐行扫描:`## ` 开头 → 版本边界,`### ` 开头 → 分类边界,
153/// 其余行归入当前分类的原始 Markdown(若无分类则归入版本 intro)。
154/// 解析完成后,每个分类的原始 Markdown 经 `render_markdown_enhanced` 渲染为 HTML。
155#[cfg(feature = "server")]
156fn parse_changelog(full_md: &str) -> ChangelogData {
157    let body = changelog_body(full_md);
158
159    let mut raw_versions: Vec<RawVersion> = Vec::new();
160    let mut current: Option<RawVersion> = None;
161    let mut current_cat: Option<(ChangeCategory, String)> = None;
162
163    for line in body.lines() {
164        if line.starts_with("## ") {
165            flush_category(&mut current, &mut current_cat);
166            flush_version(&mut current, &mut raw_versions);
167            let (version, date) = parse_version_header(line);
168            current = Some(RawVersion {
169                version,
170                date,
171                intro_md: String::new(),
172                categories: Vec::new(),
173            });
174        } else if line.starts_with("### ") {
175            flush_category(&mut current, &mut current_cat);
176            let cat_name = line.trim_start_matches("### ").trim();
177            current_cat = Some((ChangeCategory::from_name(cat_name), String::new()));
178        } else if let Some((_, md)) = current_cat.as_mut() {
179            md.push_str(line);
180            md.push('\n');
181        } else if let Some(v) = current.as_mut() {
182            v.intro_md.push_str(line);
183            v.intro_md.push('\n');
184        }
185    }
186    flush_category(&mut current, &mut current_cat);
187    flush_version(&mut current, &mut raw_versions);
188
189    // 将原始 Markdown 渲染为 HTML,转换为最终 VersionEntry。
190    let mut versions: Vec<VersionEntry> = raw_versions
191        .into_iter()
192        .map(|rv| VersionEntry {
193            version: rv.version,
194            date: rv.date,
195            is_latest: false,
196            intro_html: render_section(&rv.intro_md),
197            groups: rv
198                .categories
199                .into_iter()
200                .map(|(cat, md)| ChangeGroup {
201                    category: cat,
202                    items_html: render_section(&md),
203                })
204                .collect(),
205        })
206        .collect();
207
208    // 第一个非 Unreleased 版本标记为最新。
209    for v in versions.iter_mut() {
210        if v.version != "Unreleased" {
211            v.is_latest = true;
212            break;
213        }
214    }
215
216    ChangelogData { versions }
217}
218
219/// 将 `current_cat` 中的累积内容存入当前版本的 categories 列表。
220#[cfg(feature = "server")]
221fn flush_category(
222    current: &mut Option<RawVersion>,
223    current_cat: &mut Option<(ChangeCategory, String)>,
224) {
225    if let Some((cat, md)) = current_cat.take() {
226        if let Some(v) = current.as_mut() {
227            v.categories.push((cat, md));
228        }
229    }
230}
231
232/// 将 `current` 版本存入 `raw_versions` 列表。
233#[cfg(feature = "server")]
234fn flush_version(current: &mut Option<RawVersion>, raw_versions: &mut Vec<RawVersion>) {
235    if let Some(v) = current.take() {
236        raw_versions.push(v);
237    }
238}
239
240/// 解析版本头行。
241///
242/// `"## [0.8.3] - 2026-08-03"` → `("0.8.3", Some("2026-08-03"))`
243/// `"## [Unreleased]"`         → `("Unreleased", None)`
244#[cfg(feature = "server")]
245fn parse_version_header(line: &str) -> (String, Option<String>) {
246    let header = line.trim_start_matches("## ").trim();
247    if header.starts_with('[') {
248        if let Some(end) = header.find(']') {
249            let version = header[1..end].to_string();
250            let rest = header[end + 1..].trim();
251            let date = rest
252                .strip_prefix('-')
253                .map(|s| s.trim().to_string())
254                .filter(|s| !s.is_empty());
255            return (version, date);
256        }
257    }
258    (header.to_string(), None)
259}
260
261/// 将一段原始 Markdown 渲染为 sanitizer 清理后的 HTML 片段。
262///
263/// 复用全站统一的 `render_markdown_enhanced` 管线(内联格式 + 代码高亮 + sanitizer)。
264/// 空内容返回空字符串。
265#[cfg(feature = "server")]
266fn render_section(md: &str) -> String {
267    let trimmed = md.trim();
268    if trimmed.is_empty() {
269        return String::new();
270    }
271    crate::api::markdown::render_markdown_enhanced(trimmed).html
272}
273
274// ===========================================================================
275// Server function
276// ===========================================================================
277
278/// 获取结构化的更新日志数据。
279///
280/// 公开接口;首次调用解析 + 渲染后永久缓存,之后均为一次 `LazyLock` 读取 + clone。
281#[server(GetChangelog, "/api")]
282pub async fn get_changelog() -> Result<ChangelogData, ServerFnError> {
283    #[cfg(feature = "server")]
284    {
285        let data = tokio::task::spawn_blocking(|| CHANGELOG_PARSED.clone())
286            .await
287            .map_err(|_| crate::api::error::AppError::Internal("更新日志解析任务失败"))?;
288        Ok(data)
289    }
290
291    #[cfg(not(feature = "server"))]
292    {
293        Ok(ChangelogData {
294            versions: Vec::new(),
295        })
296    }
297}
298
299// ===========================================================================
300// 测试
301// ===========================================================================
302
303#[cfg(all(test, feature = "server"))]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn changelog_body_strips_preamble() {
309        let md = "# Changelog\n\n基于 Keep a Changelog。\n\n## [Unreleased]\n\n### Added\n- x\n\n## [0.1.0] - 2026-01-01\n";
310        let body = changelog_body(md);
311        assert!(body.starts_with("## [Unreleased]"));
312        assert!(!body.contains("# Changelog"));
313        assert!(!body.contains("Keep a Changelog"));
314    }
315
316    #[test]
317    fn changelog_body_falls_back_to_full_text_without_version_section() {
318        let md = "# Changelog\n\n这里还没有任何版本段。\n";
319        assert_eq!(changelog_body(md), md);
320    }
321
322    #[test]
323    fn parse_version_header_with_date() {
324        let (ver, date) = parse_version_header("## [0.8.3] - 2026-08-03");
325        assert_eq!(ver, "0.8.3");
326        assert_eq!(date.as_deref(), Some("2026-08-03"));
327    }
328
329    #[test]
330    fn parse_version_header_unreleased() {
331        let (ver, date) = parse_version_header("## [Unreleased]");
332        assert_eq!(ver, "Unreleased");
333        assert!(date.is_none());
334    }
335
336    #[test]
337    fn parse_version_header_no_brackets() {
338        let (ver, date) = parse_version_header("## Some Title");
339        assert_eq!(ver, "Some Title");
340        assert!(date.is_none());
341    }
342
343    #[test]
344    fn parse_changelog_basic_structure() {
345        let md = "\
346# Changelog
347
348基于 Keep a Changelog。
349
350## [Unreleased]
351
352_暂无未发布改动。_
353
354## [0.1.0] - 2026-01-01
355
356### Added
357
358- **功能 A**:描述。
359- 功能 B。
360
361### Fixed
362
363- 修复 X。
364";
365        let data = parse_changelog(md);
366        assert_eq!(data.versions.len(), 2, "应解析出 2 个版本");
367
368        // Unreleased 版本
369        let unreleased = &data.versions[0];
370        assert_eq!(unreleased.version, "Unreleased");
371        assert!(unreleased.date.is_none());
372        assert!(!unreleased.is_latest, "Unreleased 不是最新版");
373        assert!(
374            unreleased.intro_html.contains("暂无"),
375            "intro 应包含占位文字"
376        );
377        assert!(unreleased.groups.is_empty(), "Unreleased 无分类组");
378
379        // 0.1.0 版本
380        let v010 = &data.versions[1];
381        assert_eq!(v010.version, "0.1.0");
382        assert_eq!(v010.date.as_deref(), Some("2026-01-01"));
383        assert!(v010.is_latest, "第一个正式版应标记为最新");
384        assert_eq!(v010.groups.len(), 2, "应有 Added + Fixed 两个组");
385
386        // Added 组
387        let added = &v010.groups[0];
388        assert_eq!(added.category, ChangeCategory::Added);
389        assert!(added.items_html.contains("<strong>功能 A</strong>"));
390        assert!(added.items_html.contains("功能 B"));
391
392        // Fixed 组
393        let fixed = &v010.groups[1];
394        assert_eq!(fixed.category, ChangeCategory::Fixed);
395        assert!(fixed.items_html.contains("修复 X"));
396    }
397
398    #[test]
399    fn parse_changelog_nested_items() {
400        let md = "\
401## [0.1.0] - 2026-01-01
402
403### Added
404
405- **父条目**:描述。
406  - **子条目 A**:细节。
407  - **子条目 B**:细节。
408";
409        let data = parse_changelog(md);
410        let added = &data.versions[0].groups[0];
411        assert!(added.items_html.contains("父条目"), "应包含父条目");
412        assert!(added.items_html.contains("子条目 A"), "应包含嵌套子条目");
413    }
414
415    #[test]
416    fn parse_changelog_unknown_category_maps_to_internal() {
417        let md = "\
418## [0.1.0] - 2026-01-01
419
420### SomeNewCategory
421
422- 测试条目。
423";
424        let data = parse_changelog(md);
425        assert_eq!(
426            data.versions[0].groups[0].category,
427            ChangeCategory::Internal,
428            "未知分类应回退为 Internal"
429        );
430    }
431
432    #[test]
433    fn parse_changelog_empty_input() {
434        let data = parse_changelog("# Changelog\n\n没有版本段。\n");
435        assert!(data.versions.is_empty(), "无版本段时应返回空版本列表");
436    }
437
438    /// 端到端守护:include_str! 路径有效 + 真实 CHANGELOG.md 解析成功。
439    #[test]
440    fn changelog_parses_real_file() {
441        let data = &*CHANGELOG_PARSED;
442        assert!(
443            !data.versions.is_empty(),
444            "真实 CHANGELOG 应解析出至少一个版本"
445        );
446        assert!(
447            data.versions.iter().any(|v| v.version == "0.8.3"),
448            "正文应包含 0.8.3 版本"
449        );
450        // 最新版标记
451        let latest_count = data.versions.iter().filter(|v| v.is_latest).count();
452        assert_eq!(latest_count, 1, "应恰好有一个版本标记为最新");
453        // 每个正式版至少有一个组或有 intro
454        for v in &data.versions {
455            if v.version != "Unreleased" {
456                assert!(
457                    !v.groups.is_empty() || !v.intro_html.is_empty(),
458                    "版本 {} 应有内容",
459                    v.version
460                );
461            }
462        }
463        // preamble 应被剥离
464        for v in &data.versions {
465            assert!(
466                !v.intro_html.contains("keepachangelog.com"),
467                "preamble 不应出现在任何版本中"
468            );
469        }
470    }
471
472    #[test]
473    fn category_label_and_css() {
474        assert_eq!(ChangeCategory::Added.label(), "新增");
475        assert_eq!(ChangeCategory::Fixed.label(), "修复");
476        assert_eq!(ChangeCategory::Security.label(), "安全");
477        assert_eq!(ChangeCategory::Added.css_class(), "added");
478        assert_eq!(ChangeCategory::Security.css_class(), "security");
479    }
480}