yggdrasil/models/
share.rs1use serde::{Deserialize, Serialize};
7
8use super::post::Post;
9
10#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
12pub struct ShareContext {
13 pub base_url: String,
15 pub description: String,
17 pub site_image: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct PostShareCard {
24 pub title: String,
25 pub summary: String,
27 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 pub fn image_path(&self, slug: &str) -> String {
58 format!("/og/post/{slug}.png?v={}", self.version())
59 }
60}
61
62pub const SEPARATOR: &str = "·";
64
65#[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
71fn 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}