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