sqzass.git

sqzass

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

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 }