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("&"),
77 '<' => out.push_str("<"),
78 '>' => out.push_str(">"),
79 '"' => out.push_str("""),
80 '\'' => out.push_str("'"),
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&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 }