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//! 分类标签默认中性灰,只有新增(clay)与安全(danger)着色,见 `changelog.css`。
18//! `## [Unreleased]` 不进入页面数据:部署即发版,未发布段落只留在仓库文件里。
19
20// 与 settings 等模块一致:Leptos `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
21#![allow(clippy::unused_unit, deprecated)]
22
23use crate::api::error::ApiError;
24use leptos::prelude::*;
25
26// ===========================================================================
27// 数据结构(双 target 共享:server 序列化 → WASM 反序列化渲染)
28// ===========================================================================
29
30/// 变更分类。对应 Keep a Changelog 的标准分类 + 本项目扩展的 Internal。
31///
32/// 序列化为小写字符串(`"added"` / `"fixed"` …),前端据此选择 badge CSS 类。
33#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ChangeCategory {
36    Added,
37    Changed,
38    Fixed,
39    Deprecated,
40    Removed,
41    Security,
42    Internal,
43}
44
45impl ChangeCategory {
46    /// 中文标签(badge 显示文本)。
47    pub fn label(&self) -> &'static str {
48        match self {
49            Self::Added => "新增",
50            Self::Changed => "改进",
51            Self::Fixed => "修复",
52            Self::Deprecated => "弃用",
53            Self::Removed => "移除",
54            Self::Security => "安全",
55            Self::Internal => "内部",
56        }
57    }
58
59    /// 分类标签的 `data-category` 取值。
60    pub fn css_class(&self) -> &'static str {
61        match self {
62            Self::Added => "added",
63            Self::Changed => "changed",
64            Self::Fixed => "fixed",
65            Self::Deprecated => "deprecated",
66            Self::Removed => "removed",
67            Self::Security => "security",
68            Self::Internal => "internal",
69        }
70    }
71
72    /// 从 Markdown `### Header` 文本解析分类。未知分类回退为 Internal。
73    #[cfg(feature = "server")]
74    fn from_name(name: &str) -> Self {
75        match name.trim() {
76            "Added" => Self::Added,
77            "Changed" => Self::Changed,
78            "Fixed" => Self::Fixed,
79            "Deprecated" => Self::Deprecated,
80            "Removed" => Self::Removed,
81            "Security" => Self::Security,
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"`);标题未写日期时为 None。
102    pub date: Option<String>,
103    /// 是否为最新已发布版本(列表中的第一个)。
104    pub is_latest: bool,
105    /// 版本下不属于任何 `###` 分类的正文 HTML(如版本导语)。
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    // 部署即发版,页面不展示未发布段落(内容仍留在 CHANGELOG.md)。
190    raw_versions.retain(|v| v.version != "Unreleased");
191
192    // 将原始 Markdown 渲染为 HTML,转换为最终 VersionEntry。
193    let mut versions: Vec<VersionEntry> = raw_versions
194        .into_iter()
195        .map(|rv| VersionEntry {
196            version: rv.version,
197            date: rv.date,
198            is_latest: false,
199            intro_html: render_section(&rv.intro_md),
200            groups: rv
201                .categories
202                .into_iter()
203                .map(|(cat, md)| ChangeGroup {
204                    category: cat,
205                    items_html: render_section(&md),
206                })
207                .collect(),
208        })
209        .collect();
210
211    if let Some(latest) = versions.first_mut() {
212        latest.is_latest = true;
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, endpoint = "get_changelog", input = leptos::server_fn::codec::Json)]
281pub async fn get_changelog() -> Result<ChangelogData, ApiError> {
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#[server(LatestRelease, endpoint = "latest_release", input = leptos::server_fn::codec::Json)]
300pub async fn latest_release() -> Result<Option<(String, Option<String>)>, ApiError> {
301    let data = get_changelog().await?;
302    Ok(data
303        .versions
304        .into_iter()
305        .next()
306        .map(|v| (v.version, v.date)))
307}
308
309// ===========================================================================
310// 测试
311// ===========================================================================
312
313#[cfg(all(test, feature = "server"))]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn changelog_body_strips_preamble() {
319        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";
320        let body = changelog_body(md);
321        assert!(body.starts_with("## [Unreleased]"));
322        assert!(!body.contains("# Changelog"));
323        assert!(!body.contains("Keep a Changelog"));
324    }
325
326    #[test]
327    fn changelog_body_falls_back_to_full_text_without_version_section() {
328        let md = "# Changelog\n\n这里还没有任何版本段。\n";
329        assert_eq!(changelog_body(md), md);
330    }
331
332    #[test]
333    fn parse_version_header_with_date() {
334        let (ver, date) = parse_version_header("## [0.8.3] - 2026-08-03");
335        assert_eq!(ver, "0.8.3");
336        assert_eq!(date.as_deref(), Some("2026-08-03"));
337    }
338
339    #[test]
340    fn parse_version_header_unreleased() {
341        let (ver, date) = parse_version_header("## [Unreleased]");
342        assert_eq!(ver, "Unreleased");
343        assert!(date.is_none());
344    }
345
346    #[test]
347    fn parse_version_header_no_brackets() {
348        let (ver, date) = parse_version_header("## Some Title");
349        assert_eq!(ver, "Some Title");
350        assert!(date.is_none());
351    }
352
353    #[test]
354    fn parse_changelog_basic_structure() {
355        let md = "\
356# Changelog
357
358基于 Keep a Changelog。
359
360## [Unreleased]
361
362_暂无未发布改动。_
363
364### Added
365
366- 不应出现在页面数据里。
367
368## [0.1.0] - 2026-01-01
369
370### Added
371
372- **功能 A**:描述。
373- 功能 B。
374
375### Fixed
376
377- 修复 X。
378";
379        let data = parse_changelog(md);
380        assert_eq!(data.versions.len(), 1, "Unreleased 不应进入页面数据");
381        assert!(
382            data.versions.iter().all(|v| v.version != "Unreleased"
383                && !v.intro_html.contains("暂无")
384                && v.groups.iter().all(|g| !g.items_html.contains("不应出现"))),
385            "Unreleased 的导语和条目都不应出现"
386        );
387
388        let v010 = &data.versions[0];
389        assert_eq!(v010.version, "0.1.0");
390        assert_eq!(v010.date.as_deref(), Some("2026-01-01"));
391        assert!(v010.is_latest, "第一个已发布版本应标记为最新");
392        assert_eq!(v010.groups.len(), 2, "应有 Added + Fixed 两个组");
393
394        // Added 组
395        let added = &v010.groups[0];
396        assert_eq!(added.category, ChangeCategory::Added);
397        assert!(added.items_html.contains("<strong>功能 A</strong>"));
398        assert!(added.items_html.contains("功能 B"));
399
400        // Fixed 组
401        let fixed = &v010.groups[1];
402        assert_eq!(fixed.category, ChangeCategory::Fixed);
403        assert!(fixed.items_html.contains("修复 X"));
404    }
405
406    #[test]
407    fn parse_changelog_nested_items() {
408        let md = "\
409## [0.1.0] - 2026-01-01
410
411### Added
412
413- **父条目**:描述。
414  - **子条目 A**:细节。
415  - **子条目 B**:细节。
416";
417        let data = parse_changelog(md);
418        let added = &data.versions[0].groups[0];
419        assert!(added.items_html.contains("父条目"), "应包含父条目");
420        assert!(added.items_html.contains("子条目 A"), "应包含嵌套子条目");
421    }
422
423    #[test]
424    fn parse_changelog_unknown_category_maps_to_internal() {
425        let md = "\
426## [0.1.0] - 2026-01-01
427
428### SomeNewCategory
429
430- 测试条目。
431";
432        let data = parse_changelog(md);
433        assert_eq!(
434            data.versions[0].groups[0].category,
435            ChangeCategory::Internal,
436            "未知分类应回退为 Internal"
437        );
438    }
439
440    #[test]
441    fn parse_changelog_empty_input() {
442        let data = parse_changelog("# Changelog\n\n没有版本段。\n");
443        assert!(data.versions.is_empty(), "无版本段时应返回空版本列表");
444    }
445
446    /// 端到端守护:include_str! 路径有效 + 真实 CHANGELOG.md 解析成功。
447    #[test]
448    fn changelog_parses_real_file() {
449        let data = &*CHANGELOG_PARSED;
450        assert!(
451            !data.versions.is_empty(),
452            "真实 CHANGELOG 应解析出至少一个版本"
453        );
454        assert!(
455            data.versions.iter().any(|v| v.version == "0.8.3"),
456            "正文应包含 0.8.3 版本"
457        );
458        // 最新版标记
459        let latest_count = data.versions.iter().filter(|v| v.is_latest).count();
460        assert_eq!(latest_count, 1, "应恰好有一个版本标记为最新");
461        // 每个正式版至少有一个组或有 intro
462        for v in &data.versions {
463            assert_ne!(v.version, "Unreleased", "页面数据不应包含 Unreleased");
464            assert!(
465                !v.groups.is_empty() || !v.intro_html.is_empty(),
466                "版本 {} 应有内容",
467                v.version
468            );
469        }
470        // preamble 应被剥离
471        for v in &data.versions {
472            assert!(
473                !v.intro_html.contains("keepachangelog.com"),
474                "preamble 不应出现在任何版本中"
475            );
476        }
477    }
478
479    #[test]
480    fn category_label_and_css() {
481        assert_eq!(ChangeCategory::Added.label(), "新增");
482        assert_eq!(ChangeCategory::Fixed.label(), "修复");
483        assert_eq!(ChangeCategory::Security.label(), "安全");
484        assert_eq!(ChangeCategory::Added.css_class(), "added");
485        assert_eq!(ChangeCategory::Security.css_class(), "security");
486    }
487}