src/feed.rs (11199B)
1 //! 날짜와 Atom 피드.
2 //!
3 //! **Atom이고 RSS 2.0이 아니다.** RSS의 `<pubDate>`는 RFC 2822라서
4 //! `Tue, 26 Jul 2026 …` 처럼 **요일과 영어 월 이름**을 우리가 만들어 넣어야 한다.
5 //! 요일은 직접 계산해야 하고, 영어 월 이름은 한국어 피드에도 그대로 박힌다.
6 //! Atom은 RFC 3339를 쓰는데 그건 TOML 날짜가 이미 갖고 있는 모양이라, 코드가
7 //! 줄고 틀릴 자리도 줄어든다. 요즘 리더는 둘 다 읽는다.
8 //!
9 //! **날짜가 하나도 없는 언어에는 피드를 만들지 않는다.** 빈 피드는 없는 것보다
10 //! 나쁘다 — 구독자는 아무것도 오지 않는 걸 고장으로 읽는다.
11
12 use crate::content::Page;
13 use serde::Serialize;
14
15 pub const MAX_ENTRIES: usize = 20;
16
17 /// 해당 언어 피드의 출력 경로. 검색 색인과 같은 규칙을 쓴다.
18 pub fn path(language: &str) -> String {
19 format!("feed-{language}.xml")
20 }
21
22 /// 템플릿에 나가는 날짜.
23 ///
24 /// 포맷 필터를 만들지 않고 조각을 준다. `{{ page.date.year }}년 {{ page.date.month }}월`
25 /// 처럼 템플릿이 조립하면 되고, 그러면 날짜 서식 미니 언어를 하나 더 배울 필요도,
26 /// 우리가 로케일별 서식을 떠안을 일도 없다.
27 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28 pub struct PageDate {
29 pub year: u16,
30 pub month: u8,
31 pub day: u8,
32 /// `2026-07-26` — `<time datetime>` 속성에 그대로 쓸 수 있다.
33 pub date: String,
34 /// `2026-07-26T00:00:00Z` 또는 `2026-07-26T09:00:00+09:00` — RFC 3339.
35 pub iso: String,
36 /// 시간대를 UTC로 옮긴 뒤의 정렬용 값. 서로 다른 오프셋의 순간을 비교하려면
37 /// 표기 그대로가 아니라 같은 기준으로 봐야 한다.
38 utc_minutes: i64,
39 }
40
41 impl PageDate {
42 /// TOML 날짜에서 만든다. 시각이 없으면 자정 UTC로 본다 — Atom은 날짜만으로는
43 /// 유효하지 않고, 하루 안에서의 순서는 어차피 저자가 정하지 않은 것이다.
44 pub fn from_toml(dt: &toml::value::Datetime) -> Option<Self> {
45 let d = dt.date?;
46 let t = dt.time;
47 let (h, mi, s) = t.map_or((0, 0, 0), |t| (t.hour, t.minute, t.second.unwrap_or(0)));
48
49 // 오프셋은 **그대로 싣는다.** 한때 전부 `Z`로 바꿔 적었는데, 그건
50 // `09:00+09:00`(한국 아침)을 `09:00Z`(한국 저녁)라고 발표하는 것이라
51 // 최대 하루까지 틀린 순간이 나간다. RFC 3339는 어떤 오프셋도 받으므로
52 // 시각 산술 없이 저자가 쓴 것을 그대로 두면 된다.
53 let (suffix, off_min) = match dt.offset {
54 Some(toml::value::Offset::Custom { minutes }) if minutes != 0 => {
55 let (sign, m) = if minutes < 0 {
56 ('-', -i32::from(minutes))
57 } else {
58 ('+', i32::from(minutes))
59 };
60 (
61 format!("{sign}{:02}:{:02}", m / 60, m % 60),
62 i64::from(minutes),
63 )
64 }
65 _ => ("Z".to_string(), 0),
66 };
67
68 // 정렬은 UTC 기준으로 한다. 표기가 다른 두 순간을 문자열로 비교하면
69 // `+09:00`이 붙은 아침이 `Z`가 붙은 저녁보다 뒤에 온다.
70 let days = i64::from(d.year) * 372 + i64::from(d.month) * 31 + i64::from(d.day);
71 let utc_minutes = days * 1440 + i64::from(h) * 60 + i64::from(mi) - off_min;
72
73 Some(Self {
74 year: d.year,
75 month: d.month,
76 day: d.day,
77 date: format!("{:04}-{:02}-{:02}", d.year, d.month, d.day),
78 iso: format!(
79 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{suffix}",
80 d.year, d.month, d.day, h, mi, s
81 ),
82 utc_minutes,
83 })
84 }
85
86 /// 정렬용 키. UTC로 옮긴 분 단위 값이라 시각과 시간대까지 반영된다.
87 /// 날짜만 비교하면 같은 날의 아침과 저녁이 구분되지 않고, 그러면 피드의
88 /// `updated`가 자기 항목보다 과거가 되는 일이 생긴다.
89 pub fn sort_key(&self) -> i64 {
90 self.utc_minutes
91 }
92 }
93
94 /// XML 텍스트/속성값 이스케이프.
95 ///
96 /// ⚠️ 이스케이프만으로는 부족하다. XML 1.0은 C0 제어문자를 **문자 참조로도** 담을
97 /// 수 없다고 정한다. 제목에 그런 글자가 하나 섞이면 피드 전체가 파싱되지 않고,
98 /// 빌드는 성공했다고 말한다. 그래서 넣을 수 없는 건 지운다.
99 fn esc(s: &str) -> String {
100 let mut out = String::with_capacity(s.len());
101 for ch in s.chars() {
102 match ch {
103 c if c < '\u{20}' && c != '\t' && c != '\n' && c != '\r' => {}
104 '&' => out.push_str("&"),
105 '<' => out.push_str("<"),
106 '>' => out.push_str(">"),
107 '"' => out.push_str("""),
108 '\'' => out.push_str("'"),
109 _ => out.push(ch),
110 }
111 }
112 out
113 }
114
115 /// 날짜가 있는 페이지들로 Atom 피드를 만든다. 하나도 없으면 `None`.
116 ///
117 /// `home_url`은 이 언어의 첫 페이지 URL이다(`/` 또는 `/ko/`).
118 pub fn atom(
119 pages: &[&Page],
120 language: &str,
121 site_title: &str,
122 origin: &str,
123 home_url: &str,
124 self_url: &str,
125 ) -> Option<String> {
126 let mut dated: Vec<(&Page, PageDate)> = pages
127 .iter()
128 .filter_map(|p| {
129 p.front
130 .date
131 .as_ref()
132 .and_then(PageDate::from_toml)
133 .map(|d| (*p, d))
134 })
135 .collect();
136 if dated.is_empty() {
137 return None;
138 }
139
140 // 최신이 먼저. 같은 날이면 제목순 — 파일 순회 순서에 기대면 빌드가 결정적이지 않다.
141 dated.sort_by(|a, b| {
142 b.1.sort_key()
143 .cmp(&a.1.sort_key())
144 .then_with(|| a.0.title.cmp(&b.0.title))
145 });
146
147 dated.truncate(MAX_ENTRIES);
148 // 정렬의 머리가 아니라 **최대값**이다. 같은 날 여러 글이 있으면 정렬은 제목으로
149 // 갈리므로, 머리를 쓰면 피드가 자기 항목보다 과거 시각을 발표하게 된다.
150 // 자르기 뒤에 계산해야 피드에 없는 항목의 시각을 쓰지 않는다.
151 let updated = dated
152 .iter()
153 .max_by_key(|(_, d)| d.sort_key())
154 .map(|(_, d)| d.iso.clone())
155 .expect("비어 있지 않음을 위에서 확인했다");
156
157 let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
158 out.push_str(&format!(
159 "<feed xmlns=\"http://www.w3.org/2005/Atom\" xml:lang=\"{}\">\n",
160 esc(language)
161 ));
162 out.push_str(&format!(" <title>{}</title>\n", esc(site_title)));
163 // id는 영원히 바뀌지 않아야 하고, **다른 무엇과도 같으면 안 된다.** 홈 URL을
164 // 쓰면 홈 페이지 entry의 id와 글자 그대로 같아져서, 리더가 피드와 그 안의 항목
165 // 하나를 같은 것으로 본다. 피드 자기 주소는 그럴 일이 없다.
166 out.push_str(&format!(" <id>{}{}</id>\n", esc(origin), esc(self_url)));
167 out.push_str(&format!(
168 " <link rel=\"alternate\" href=\"{}{}\"/>\n",
169 esc(origin),
170 esc(home_url)
171 ));
172 out.push_str(&format!(
173 " <link rel=\"self\" href=\"{}{}\"/>\n",
174 esc(origin),
175 esc(self_url)
176 ));
177 out.push_str(&format!(" <updated>{}</updated>\n", esc(&updated)));
178 // RFC 4287은 feed나 모든 entry 중 한쪽에 author를 **요구한다**. 없으면 검증기가
179 // 무효로 판정하고, 일부 리더는 피드를 통째로 거른다. 사람 이름을 위한 설정 키를
180 // 새로 만드는 대신 사이트 제목을 쓴다 — 사이트 피드의 저자는 사이트다.
181 out.push_str(&format!(
182 " <author><name>{}</name></author>\n",
183 esc(site_title)
184 ));
185
186 for (page, date) in &dated {
187 let permalink = format!("{origin}{}", page.url);
188 out.push_str(" <entry>\n");
189 out.push_str(&format!(" <title>{}</title>\n", esc(&page.title)));
190 out.push_str(&format!(" <id>{}</id>\n", esc(&permalink)));
191 out.push_str(&format!(
192 " <link rel=\"alternate\" href=\"{}\"/>\n",
193 esc(&permalink)
194 ));
195 out.push_str(&format!(" <updated>{}</updated>\n", esc(&date.iso)));
196 if !page.front.description.is_empty() {
197 out.push_str(&format!(
198 " <summary>{}</summary>\n",
199 esc(&page.front.description)
200 ));
201 }
202 out.push_str(" </entry>\n");
203 }
204
205 out.push_str("</feed>\n");
206 Some(out)
207 }
208
209 #[cfg(test)]
210 mod tests {
211 use super::*;
212
213 fn dt(s: &str) -> toml::value::Datetime {
214 s.parse().unwrap()
215 }
216
217 #[test]
218 fn a_date_without_a_time_becomes_midnight_utc() {
219 // Atom은 날짜만으로는 유효하지 않다. `2026-07-26`을 그대로 내보내면
220 // 리더가 항목을 조용히 버린다.
221 let d = PageDate::from_toml(&dt("2026-07-26")).unwrap();
222 assert_eq!(d.date, "2026-07-26");
223 assert_eq!(d.iso, "2026-07-26T00:00:00Z");
224 assert_eq!((d.year, d.month, d.day), (2026, 7, 26));
225 }
226
227 #[test]
228 fn a_full_timestamp_keeps_its_time() {
229 let d = PageDate::from_toml(&dt("2026-07-26T09:30:00Z")).unwrap();
230 assert_eq!(d.iso, "2026-07-26T09:30:00Z");
231 }
232
233 /// 빈 피드는 없는 것보다 나쁘다. 구독자는 아무것도 오지 않는 걸 고장으로 읽는다.
234 #[test]
235 fn no_dated_pages_means_no_feed() {
236 assert!(atom(&[], "en", "S", "https://e.com", "/", "/feed-en.xml").is_none());
237 }
238
239 #[test]
240 fn sort_keys_order_by_calendar_not_by_string() {
241 let a = PageDate::from_toml(&dt("2026-09-01")).unwrap();
242 let b = PageDate::from_toml(&dt("2026-10-01")).unwrap();
243 assert!(b.sort_key() > a.sort_key());
244 }
245
246 /// 오프셋을 전부 Z로 바꿔 적던 시절이 있었다. 그건 한국 아침 9시를 한국
247 /// 저녁 6시라고 발표하는 것이라 최대 하루까지 틀린 순간이 나갔다.
248 #[test]
249 fn an_offset_is_carried_not_relabelled() {
250 let d = PageDate::from_toml(&dt("2026-07-26T09:00:00+09:00")).unwrap();
251 assert_eq!(d.iso, "2026-07-26T09:00:00+09:00");
252 // 그리고 정렬은 UTC 기준이라, 같은 날 18:00Z가 09:00+09:00보다 뒤다.
253 let z = PageDate::from_toml(&dt("2026-07-26T18:00:00Z")).unwrap();
254 assert!(
255 z.sort_key() > d.sort_key(),
256 "표기가 아니라 순간으로 비교해야 한다"
257 );
258 }
259
260 /// XML 1.0은 C0 제어문자를 문자 참조로도 담을 수 없다. 제목에 하나만 섞여도
261 /// 피드 전체가 파싱되지 않는데, 빌드는 성공했다고 말한다.
262 #[test]
263 fn control_characters_are_dropped_not_escaped() {
264 let out = esc("Bell \u{7} here\ttab");
265 assert!(!out.contains('\u{7}'), "실제: {out:?}");
266 assert!(out.contains('\t'), "탭은 XML에 넣을 수 있다: {out:?}");
267 assert_eq!(out, "Bell here\ttab");
268 }
269 }