Skip to main content

yggdrasil/api/posts/
outline.rs

1//! 首页「最新一篇」的目录预览接口。
2//!
3//! 读取最新已发布文章已存储的 `toc_html`,在服务端解析一级小节,不重新渲染
4//! Markdown。结果随文章列表缓存一起失效。
5//! Leptos server function,注册在 `/api` 路径下。
6
7use crate::api::error::ApiError;
8use leptos::prelude::*;
9
10use super::types::PostOutline;
11#[cfg(feature = "server")]
12use super::types::OUTLINE_PREVIEW_LIMIT;
13#[cfg(feature = "server")]
14use crate::api::error::AppError;
15#[cfg(feature = "server")]
16use crate::db::pool::get_conn;
17
18/// 获取最新一篇已发布文章的目录预览;没有文章或文章没有小节时返回 `None`。
19///
20/// 排序与 `list_published_posts` 一致,前台再用 slug 核对首篇,防止两次读取之间
21/// 有新文章发布造成错配。
22#[server(LatestPostOutline, endpoint = "latest_post_outline", input = leptos::server_fn::codec::Json)]
23pub async fn latest_post_outline() -> Result<Option<PostOutline>, ApiError> {
24    #[cfg(feature = "server")]
25    {
26        if let Some(cached) = crate::cache::get_latest_outline().await {
27            return Ok(cached);
28        }
29        let client = get_conn().await.map_err(AppError::db_conn)?;
30        let row = client
31            .query_opt(
32                "SELECT slug, toc_html FROM posts
33                 WHERE status = 'published' AND deleted_at IS NULL
34                 ORDER BY published_at DESC
35                 LIMIT 1",
36                &[],
37            )
38            .await
39            .map_err(AppError::query)?;
40        let outline = row.and_then(|row| {
41            let toc: Option<String> = row.get("toc_html");
42            outline_from_toc(row.get("slug"), toc.as_deref().unwrap_or_default())
43        });
44        crate::cache::set_latest_outline(outline.clone()).await;
45        Ok(outline)
46    }
47
48    #[cfg(not(feature = "server"))]
49    {
50        Ok(None)
51    }
52}
53
54#[cfg(feature = "server")]
55fn outline_from_toc(slug: String, toc_html: &str) -> Option<PostOutline> {
56    let sections = crate::utils::toc::toc_sections(toc_html, OUTLINE_PREVIEW_LIMIT);
57    (sections.total > 0).then(|| PostOutline {
58        slug,
59        total: u32::try_from(sections.total).unwrap_or(u32::MAX),
60        headings: sections.headings,
61    })
62}
63
64#[cfg(all(test, feature = "server"))]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn article_without_headings_has_no_outline() {
70        assert_eq!(outline_from_toc("a".into(), ""), None);
71    }
72
73    #[test]
74    fn outline_keeps_six_headings_and_total() {
75        let md: String = (1..=8)
76            .map(|i| format!("## 第 {i} 节\n\n正文\n\n"))
77            .collect();
78        let toc = crate::api::markdown::render_markdown_enhanced(&md).toc_html;
79        let outline = outline_from_toc("post".into(), &toc).expect("有小节");
80        assert_eq!(outline.slug, "post");
81        assert_eq!(outline.total, 8);
82        assert_eq!(outline.headings.len(), OUTLINE_PREVIEW_LIMIT);
83        assert_eq!(outline.headings[0], "第 1 节");
84    }
85}