sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

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("&amp;"),
    105             '<' => out.push_str("&lt;"),
    106             '>' => out.push_str("&gt;"),
    107             '"' => out.push_str("&quot;"),
    108             '\'' => out.push_str("&apos;"),
    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 }