sqzass.git

sqzass

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

src/seo.rs (6706B)

      1 //! `sitemap.xml`과 `robots.txt`.
      2 //!
      3 //! 둘 다 사이트를 빌드하면 그냥 생긴다. 크롤러가 이름으로 찾는 파일이라 이름이 곧
      4 //! 계약이고, 그래서 콘텐츠 해시를 붙이지 않는다.
      5 //!
      6 //! **`static/`에 같은 이름의 파일이 있으면 그쪽이 이긴다.** 생성물을 덮어쓰는 게
      7 //! 아니라 애초에 만들지 않는다 — 자기 `robots.txt`를 넣었는데 조용히 무시당하는
      8 //! 것만큼 나쁜 것도 없다.
      9 
     10 use crate::content::Page;
     11 use crate::site::Site;
     12 
     13 pub const SITEMAP_PATH: &str = "sitemap.xml";
     14 pub const ROBOTS_PATH: &str = "robots.txt";
     15 
     16 /// `<loc>`과 언어 대체 링크만 낸다.
     17 ///
     18 /// **`priority`와 `changefreq`는 넣지 않는다.** 구글은 2023년에 둘 다 무시한다고
     19 /// 명시했고, 무시되는 값을 만드는 코드는 그냥 틀릴 기회일 뿐이다.
     20 ///
     21 /// **`lastmod`도 넣지 않는다.** 믿을 만한 출처가 없기 때문이다. 파일 mtime은
     22 /// 체크아웃 시각이라 CI에서는 전부 "방금"이 되고, 빌드가 결정적이어야 한다는
     23 /// 원칙과도 어긋난다. git 커밋 시각은 정확하지만 전체 이력이 있어야 하는데
     24 /// `actions/checkout`은 기본이 얕은 클론이라, 조용히 모든 페이지가 같은 날짜를
     25 /// 갖게 된다. 틀린 `lastmod`는 없는 것보다 나쁘다 — 구글은 부정확한 값을 보면
     26 /// 그 사이트의 `lastmod`를 통째로 무시한다.
     27 pub fn sitemap(pages: &[Page], site: &Site, origin: &str) -> String {
     28     let mut out = String::from(
     29         "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
     30          <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" \
     31          xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n",
     32     );
     33 
     34     for page in pages {
     35         out.push_str("  <url>\n    <loc>");
     36         escape_into(&mut out, &format!("{origin}{}", page.url));
     37         out.push_str("</loc>\n");
     38 
     39         // 번역이 있는 페이지만 대체 링크를 낸다. 혼자인 페이지에 자기 자신만
     40         // 가리키는 hreflang을 다는 건 바이트 낭비다.
     41         let langs = site.language_set_of(page);
     42         if langs.len() > 1 {
     43             for (code, url) in langs {
     44                 out.push_str("    <xhtml:link rel=\"alternate\" hreflang=\"");
     45                 escape_into(&mut out, &code);
     46                 out.push_str("\" href=\"");
     47                 escape_into(&mut out, &format!("{origin}{url}"));
     48                 out.push_str("\"/>\n");
     49             }
     50         }
     51         out.push_str("  </url>\n");
     52     }
     53 
     54     out.push_str("</urlset>\n");
     55     out
     56 }
     57 
     58 /// 전부 허용하고 sitemap을 가리킨다.
     59 ///
     60 /// 크롤러를 막고 싶으면 `static/robots.txt`에 직접 쓰면 된다.
     61 pub fn robots(origin: &str, base_path: &str) -> String {
     62     // sitemap은 출력 루트에 있고, 출력 루트는 도메인의 `base_path` 아래에 놓인다.
     63     format!("User-agent: *\nAllow: {base_path}/\n\nSitemap: {origin}{base_path}/{SITEMAP_PATH}\n")
     64 }
     65 
     66 /// XML 텍스트/속성값 이스케이프.
     67 ///
     68 /// URL에는 쿼리가 붙을 수 있고 `&`는 XML에서 엔티티의 시작이다. 이걸 빼먹으면
     69 /// sitemap 전체가 파싱되지 않는데, 크롤러는 그 사실을 알려주지 않는다.
     70 fn escape_into(out: &mut String, s: &str) {
     71     for ch in s.chars() {
     72         match ch {
     73             // XML 1.0은 C0 제어문자를 문자 참조로도 담을 수 없다. 지금은 URL과
     74             // 언어 코드만 지나가지만, 언젠가 제목이 지나가면 sitemap 전체가 깨진다.
     75             c if c < '\u{20}' && c != '\t' && c != '\n' && c != '\r' => {}
     76             '&' => out.push_str("&amp;"),
     77             '<' => out.push_str("&lt;"),
     78             '>' => out.push_str("&gt;"),
     79             '"' => out.push_str("&quot;"),
     80             '\'' => out.push_str("&apos;"),
     81             _ => out.push(ch),
     82         }
     83     }
     84 }
     85 
     86 #[cfg(test)]
     87 mod tests {
     88     use super::*;
     89 
     90     #[test]
     91     fn robots_points_at_the_sitemap() {
     92         let r = robots("https://example.com", "");
     93         assert!(
     94             r.contains("Sitemap: https://example.com/sitemap.xml"),
     95             "{r}"
     96         );
     97     }
     98 
     99     #[test]
    100     fn escapes_ampersands() {
    101         let mut s = String::new();
    102         escape_into(&mut s, "https://example.com/a?b=1&c=2");
    103         assert_eq!(s, "https://example.com/a?b=1&amp;c=2");
    104     }
    105 
    106     /// 번역이 있는 페이지에는 hreflang 세트를, 없는 페이지에는 `<loc>`만.
    107     #[test]
    108     fn sitemap_lists_every_page_and_its_translations() {
    109         let cfg: crate::config::Config = toml::from_str(
    110             r#"
    111             title = "t"
    112             base_url = "https://example.com"
    113             default_language = "en"
    114             [languages.en]
    115             name = "English"
    116             weight = 1
    117             [languages.ko]
    118             name = "한국어"
    119             weight = 2
    120             "#,
    121         )
    122         .unwrap();
    123 
    124         let root = std::env::temp_dir().join(format!("sqzass-seo-{}", std::process::id()));
    125         let _ = std::fs::remove_dir_all(&root);
    126         for (rel, title) in [
    127             ("_index.md", "Home"),
    128             ("_index.ko.md", "홈"),
    129             ("solo.md", "Solo"),
    130         ] {
    131             let path = root.join("content").join(rel);
    132             std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    133             std::fs::write(path, format!("+++\ntitle = \"{title}\"\n+++\n\nbody\n")).unwrap();
    134         }
    135         let pages = crate::content::discover(&root, &cfg, false).unwrap();
    136         let site = Site::build(&pages, &cfg);
    137         let xml = sitemap(&pages, &site, "https://example.com");
    138         let _ = std::fs::remove_dir_all(&root);
    139 
    140         assert_eq!(xml.matches("<loc>").count(), 3, "페이지 3개: {xml}");
    141         assert!(xml.contains("<loc>https://example.com/</loc>"), "{xml}");
    142         assert!(xml.contains("<loc>https://example.com/ko/</loc>"), "{xml}");
    143         assert!(
    144             xml.contains("<loc>https://example.com/solo/</loc>"),
    145             "{xml}"
    146         );
    147 
    148         // 번역이 있는 홈은 자기 자신을 포함해 둘 다 나열해야 한다.
    149         assert_eq!(xml.matches("hreflang=\"en\"").count(), 2, "{xml}");
    150         assert_eq!(xml.matches("hreflang=\"ko\"").count(), 2, "{xml}");
    151         // 혼자인 페이지에는 대체 링크가 없다.
    152         let solo = xml.split("<url>").find(|u| u.contains("/solo/")).unwrap();
    153         assert!(!solo.contains("xhtml:link"), "{solo}");
    154 
    155         assert!(!xml.contains("priority"), "구글이 무시하는 값이다: {xml}");
    156         assert!(!xml.contains("changefreq"), "구글이 무시하는 값이다: {xml}");
    157         assert!(!xml.contains("lastmod"), "믿을 만한 출처가 없다: {xml}");
    158     }
    159 }