src/emit.rs (6164B)
1 //! 페이지 말고도 빌드가 내보내는 것들 — 리다이렉트 스텁, 404, llms.txt.
2 //!
3 //! 전부 "호스트가 안 해 주니 우리가 한다"는 같은 이유에서 나온다. GitHub Pages에는
4 //! 리다이렉트 규칙이 없고, 404 페이지는 이름으로 찾으며, 그 둘 다 빌드가 만들지
5 //! 않으면 아무도 만들어 주지 않는다.
6
7 use crate::content::Page;
8 use anyhow::{Result, bail};
9
10 /// 호스트들이 이름으로 찾는 파일. `/404/index.html`은 아무도 찾지 않는다.
11 pub const NOT_FOUND_PATH: &str = "404.html";
12 pub const LLMS_PATH: &str = "llms.txt";
13
14 /// `aliases` 항목 하나를 출력 경로로 바꾼다.
15 ///
16 /// 스킴이 붙었거나 루트 절대 경로가 아니면 에러다. 상대 경로를 허용하면 "무엇에
17 /// 상대적인가"라는 질문이 생기고, 그 답이 파일 위치인지 URL인지 사람마다 다르게
18 /// 읽는다.
19 pub fn alias_output_path(alias: &str, page: &Page) -> Result<String> {
20 if alias.contains("://") || alias.starts_with("//") {
21 bail!(
22 "{}: alias `{alias}` 는 외부 URL입니다. alias는 이 사이트 안의 \
23 루트 절대 경로여야 합니다 (예: `/old-name/`).",
24 page.source.display()
25 );
26 }
27 if !alias.starts_with('/') {
28 bail!(
29 "{}: alias `{alias}` 가 `/`로 시작하지 않습니다. \
30 alias는 루트 절대 경로여야 합니다 (예: `/old-name/`).",
31 page.source.display()
32 );
33 }
34
35 let trimmed = alias.trim_matches('/');
36 if trimmed.is_empty() {
37 bail!(
38 "{}: alias `{alias}` 는 사이트 루트를 가리킵니다. \
39 루트는 `_index.md`가 이미 차지하고 있습니다.",
40 page.source.display()
41 );
42 }
43 Ok(format!("{trimmed}/index.html"))
44 }
45
46 /// meta-refresh 리다이렉트 스텁.
47 ///
48 /// 정적 호스트에서 쓸 수 있는 유일한 수단이다. canonical로 원본을 가리키고
49 /// `noindex`를 붙여, 검색 엔진이 스텁 자체를 색인하지 않게 한다. 본문에 링크를
50 /// 두는 건 자동 이동이 막힌 환경(스크린리더 설정, 확장 프로그램)을 위한 것이다.
51 pub fn redirect_stub(target_url: &str, permalink: &str) -> String {
52 format!(
53 "<!doctype html>\n\
54 <html lang=\"en\">\n\
55 <head>\n\
56 <meta charset=\"utf-8\">\n\
57 <title>Redirecting…</title>\n\
58 <link rel=\"canonical\" href=\"{permalink}\">\n\
59 <meta name=\"robots\" content=\"noindex\">\n\
60 <meta http-equiv=\"refresh\" content=\"0; url={target_url}\">\n\
61 </head>\n\
62 <body><p>이 페이지는 <a href=\"{target_url}\">{target_url}</a> 로 옮겨졌습니다.</p></body>\n\
63 </html>\n"
64 )
65 }
66
67 /// `llms.txt` — 언어 모델이 사이트 전체를 훑지 않고도 구조를 알 수 있는 목록.
68 ///
69 /// [제안 규격](https://llmstxt.org)은 제목, 요약, 그리고 링크 목록이다. 우리는 이미
70 /// 제목·설명·URL을 전부 갖고 있으므로 새로 만들 데이터가 없다.
71 pub fn llms_txt(title: &str, description: &str, origin: &str, pages: &[Page]) -> String {
72 let mut out = format!("# {title}\n");
73 if !description.is_empty() {
74 out.push_str(&format!("\n> {description}\n"));
75 }
76 out.push_str("\n## Pages\n\n");
77 for page in pages {
78 out.push_str(&format!("- [{}]({origin}{})", page.title, page.url));
79 if !page.front.description.is_empty() {
80 out.push_str(&format!(": {}", page.front.description));
81 }
82 out.push('\n');
83 }
84 out
85 }
86
87 #[cfg(test)]
88 mod tests {
89 use super::*;
90
91 fn page() -> Page {
92 Page {
93 source: "content/new.md".into(),
94 rel: "new.md".into(),
95 front: crate::content::FrontMatter::default(),
96 title: "New".into(),
97 body: String::new(),
98 body_line_offset: 1,
99 language: "en".into(),
100 translation_key: "new".into(),
101 url: "/new/".into(),
102 out_path: "new/index.html".into(),
103 is_section: false,
104 }
105 }
106
107 #[test]
108 fn alias_becomes_a_directory_index() {
109 assert_eq!(
110 alias_output_path("/old/", &page()).unwrap(),
111 "old/index.html"
112 );
113 assert_eq!(
114 alias_output_path("/a/b", &page()).unwrap(),
115 "a/b/index.html"
116 );
117 }
118
119 #[test]
120 fn alias_must_be_a_local_root_absolute_path() {
121 for bad in ["https://elsewhere.example/x", "//cdn.example/x"] {
122 let err = alias_output_path(bad, &page()).unwrap_err().to_string();
123 assert!(err.contains("외부 URL"), "{bad}: {err}");
124 }
125 let err = alias_output_path("old/", &page()).unwrap_err().to_string();
126 assert!(err.contains("`/`로 시작"), "{err}");
127 let err = alias_output_path("/", &page()).unwrap_err().to_string();
128 assert!(err.contains("루트"), "{err}");
129 }
130
131 /// 스텁이 색인되면 검색 결과에 옛 URL이 뜨고, 원본과 중복 콘텐츠가 된다.
132 #[test]
133 fn the_stub_points_home_and_refuses_indexing() {
134 let html = redirect_stub("/new/", "https://example.com/new/");
135 assert!(html.contains(r#"content="noindex""#), "{html}");
136 assert!(
137 html.contains(r#"rel="canonical" href="https://example.com/new/""#),
138 "{html}"
139 );
140 assert!(html.contains(r#"content="0; url=/new/""#), "{html}");
141 // 자동 이동이 막힌 환경을 위해 본문에도 링크가 있어야 한다.
142 assert!(html.contains(r#"<a href="/new/""#), "{html}");
143 }
144
145 #[test]
146 fn llms_txt_lists_every_page_with_an_absolute_url() {
147 let mut p = page();
148 p.front.description = "A page".into();
149 let txt = llms_txt("Site", "About it", "https://example.com", &[p]);
150 assert!(txt.starts_with("# Site\n"), "{txt}");
151 assert!(txt.contains("> About it"), "{txt}");
152 assert!(
153 txt.contains("- [New](https://example.com/new/): A page"),
154 "{txt}"
155 );
156 }
157 }