Skip to main content

yggdrasil/models/
share.rs

1//! 分享图的展示内容与地址版本号,服务端渲染图片与前台页面 `og:image` 共用。
2//!
3//! 图片地址带 `?v=`:内容变化时版本号随之改变,社交平台与 CDN 不会继续用旧图。
4//! 版本号按展示文字计算,两端用同一实现,结果一致。
5
6use serde::{Deserialize, Serialize};
7
8use super::post::Post;
9
10/// 写分享元信息所需的站点信息,由中间件放进请求扩展;应用根组件经 `SharedValue` 序列化进页面,浏览器水合时读回。
11#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
12pub struct ShareContext {
13    /// APP_BASE_URL,无尾斜杠;未配置时为空,地址退回站内相对路径。
14    pub base_url: String,
15    /// 站点简介,页面没有自己的描述时使用。
16    pub description: String,
17    /// 站点分享图的站内地址(带版本号)。
18    pub site_image: String,
19}
20
21/// 文章分享图上的文字。
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct PostShareCard {
24    pub title: String,
25    /// 为空时不显示摘要。
26    pub summary: String,
27    /// 「日期·阅读时长·首个标签」。
28    pub meta: String,
29}
30
31impl PostShareCard {
32    pub fn from_post(post: &Post) -> Self {
33        let at = post.published_at.unwrap_or(post.created_at);
34        let mut meta = vec![crate::utils::time::ymd(&at, '.')];
35        if post.reading_time > 0 {
36            meta.push(format!("{} 分钟", post.reading_time));
37        }
38        if let Some(tag) = post.tags.first() {
39            meta.push(tag.clone());
40        }
41        Self {
42            title: collapse_whitespace(&post.title),
43            summary: post
44                .summary
45                .as_deref()
46                .map(collapse_whitespace)
47                .unwrap_or_default(),
48            meta: meta.join(SEPARATOR),
49        }
50    }
51
52    pub fn version(&self) -> String {
53        version_of(&[&self.title, &self.summary, &self.meta])
54    }
55
56    /// 站内图片地址,形如 `/og/post/{slug}.png?v=…`。
57    pub fn image_path(&self, slug: &str) -> String {
58        format!("/og/post/{slug}.png?v={}", self.version())
59    }
60}
61
62/// 分享图上的分隔点。中文字体的「·」是全宽字形,自带两侧留白,不再加空格。
63pub const SEPARATOR: &str = "·";
64
65/// 站点分享图地址;标语变化时版本号改变。
66#[cfg(any(feature = "server", test))]
67pub fn site_image_path(title: &str, subtitle: &str) -> String {
68    format!("/og/site.png?v={}", version_of(&[title, subtitle]))
69}
70
71/// FNV-1a 64 位,取低 32 位作 8 位十六进制版本号。
72fn version_of(parts: &[&str]) -> String {
73    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
74    for part in parts {
75        for byte in part.bytes().chain(std::iter::once(0)) {
76            hash ^= u64::from(byte);
77            hash = hash.wrapping_mul(0x0100_0000_01b3);
78        }
79    }
80    format!("{:08x}", hash as u32)
81}
82
83fn collapse_whitespace(text: &str) -> String {
84    text.split_whitespace().collect::<Vec<_>>().join(" ")
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::models::post::PostStatus;
91    use chrono::{TimeZone, Utc};
92
93    fn post() -> Post {
94        let at = Utc.with_ymd_and_hms(2026, 9, 16, 8, 0, 0).unwrap();
95        Post {
96            id: 1,
97            author_id: 1,
98            title: "让时间成为\n电路的一部分".into(),
99            slug: "hesper".into(),
100            summary: Some("  从一个字符\n如何抵达屏幕出发。 ".into()),
101            content_md: String::new(),
102            content_html: None,
103            status: PostStatus::Published,
104            published_at: Some(at),
105            created_at: at,
106            updated_at: at,
107            deleted_at: None,
108            tags: vec!["源码剖析".into(), "Rust".into()],
109            cover_image: None,
110            reading_time: 19,
111            word_count: 4866,
112            toc_html: None,
113            prev_post: None,
114            next_post: None,
115        }
116    }
117
118    #[test]
119    fn card_uses_date_reading_time_and_first_tag() {
120        let card = PostShareCard::from_post(&post());
121        assert_eq!(card.title, "让时间成为 电路的一部分");
122        assert_eq!(card.summary, "从一个字符 如何抵达屏幕出发。");
123        assert_eq!(card.meta, "2026.09.16·19 分钟·源码剖析");
124    }
125
126    #[test]
127    fn card_omits_missing_reading_time_and_tags() {
128        let mut post = post();
129        post.reading_time = 0;
130        post.tags.clear();
131        post.summary = None;
132        let card = PostShareCard::from_post(&post);
133        assert_eq!(card.meta, "2026.09.16");
134        assert!(card.summary.is_empty());
135    }
136
137    #[test]
138    fn image_path_changes_with_shown_text_only() {
139        let base = post();
140        let path = PostShareCard::from_post(&base).image_path("hesper");
141        assert!(path.starts_with("/og/post/hesper.png?v="));
142
143        let mut edited = base.clone();
144        edited.content_md = "正文不在图上".into();
145        edited.word_count = 1;
146        assert_eq!(PostShareCard::from_post(&edited).image_path("hesper"), path);
147
148        edited.title = "新标题".into();
149        assert_ne!(PostShareCard::from_post(&edited).image_path("hesper"), path);
150    }
151
152    #[test]
153    fn version_separates_fields() {
154        assert_ne!(version_of(&["ab", "c"]), version_of(&["a", "bc"]));
155        assert_eq!(
156            site_image_path("慢慢读源码,", "认真写长文。").len(),
157            "/og/site.png?v=".len() + 8
158        );
159    }
160}