sqzass.git

sqzass

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

src/doctor.rs (13313B)

      1 //! `sqzass doctor` — 빌드가 통과시키지만 사람이 알아야 하는 것들.
      2 //!
      3 //! **빌드가 이미 잡는 건 여기서 다시 잡지 않는다.** 설정 오타, front matter 오타,
      4 //! URL 충돌, 해석 안 되는 `@/` 링크, 없는 템플릿·에셋·번역 키는 전부 빌드가 멈추는
      5 //! 사유다. 그걸 doctor가 또 보고하면 두 명령이 같은 말을 하게 되고, 그중 하나는
      6 //! 언젠가 낡는다.
      7 //!
      8 //! 남는 건 **틀리지는 않았지만 의도한 게 아닐 가능성이 있는 것들**이다. 그래서
      9 //! 경고이고, 그래서 게이팅 기준을 고를 수 있어야 한다.
     10 
     11 use crate::config::Config;
     12 use crate::content::Page;
     13 use crate::render::Templates;
     14 use crate::site::Site;
     15 use anyhow::Result;
     16 use serde::Serialize;
     17 use std::collections::{BTreeMap, BTreeSet};
     18 use std::path::Path;
     19 
     20 /// 찾은 게 있을 때의 exit code. `Kind`의 번호들과 겹치지 않는다 — doctor의 지적은
     21 /// 빌드 실패와 다른 종류의 사건이고, CI가 둘을 구분할 수 있어야 한다.
     22 pub const FINDINGS_EXIT_CODE: i32 = 7;
     23 
     24 /// `init`이 넣어 주는 자리표시자. 이대로 배포하면 canonical URL과 sitemap이
     25 /// 남의 도메인을 가리킨다.
     26 const PLACEHOLDER_BASE_URL: &str = "https://example.com";
     27 
     28 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
     29 #[serde(rename_all = "lowercase")]
     30 pub enum Severity {
     31     /// 알아 두면 좋은 것. 기본 게이트는 여기서 걸리지 않는다.
     32     Note,
     33     /// 의도한 게 아닐 가능성이 높은 것.
     34     Warn,
     35 }
     36 
     37 impl std::fmt::Display for Severity {
     38     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
     39         f.write_str(match self {
     40             Self::Note => "note",
     41             Self::Warn => "warn",
     42         })
     43     }
     44 }
     45 
     46 impl std::str::FromStr for Severity {
     47     type Err = String;
     48     fn from_str(s: &str) -> std::result::Result<Self, String> {
     49         match s {
     50             "note" => Ok(Self::Note),
     51             "warn" => Ok(Self::Warn),
     52             other => Err(format!("알 수 없는 심각도 '{other}' (note | warn)")),
     53         }
     54     }
     55 }
     56 
     57 #[derive(Debug, Clone, Serialize)]
     58 pub struct Finding {
     59     pub severity: Severity,
     60     /// 검사 이름. 안정된 문자열이라 스크립트가 잡아도 된다.
     61     pub check: &'static str,
     62     pub message: String,
     63     /// 관련된 소스 파일이 있으면.
     64     #[serde(skip_serializing_if = "Option::is_none")]
     65     pub file: Option<String>,
     66 }
     67 
     68 impl std::fmt::Display for Finding {
     69     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
     70         write!(f, "{}: [{}] {}", self.severity, self.check, self.message)?;
     71         if let Some(file) = &self.file {
     72             write!(f, " ({file})")?;
     73         }
     74         Ok(())
     75     }
     76 }
     77 
     78 /// 사이트를 읽고 지적할 것들을 모은다. 아무것도 쓰지 않는다.
     79 pub fn run(root: &Path, cfg: &Config, pages: &[Page], site: &Site) -> Result<Vec<Finding>> {
     80     let mut out = Vec::new();
     81 
     82     placeholder_base_url(cfg, &mut out);
     83     untranslated_pages(cfg, pages, site, &mut out);
     84     missing_descriptions(pages, &mut out);
     85     drafts(root, cfg, &mut out)?;
     86     empty_sections(cfg, site, &mut out);
     87     unused_templates(root, pages, site, &mut out)?;
     88 
     89     // 심각도가 높은 것부터, 같으면 검사 이름순. 출력 순서가 빌드처럼 결정적이어야
     90     // CI 로그를 diff 할 수 있다.
     91     out.sort_by(|a, b| {
     92         b.severity
     93             .cmp(&a.severity)
     94             .then_with(|| a.check.cmp(b.check))
     95             .then_with(|| a.message.cmp(&b.message))
     96     });
     97     Ok(out)
     98 }
     99 
    100 fn placeholder_base_url(cfg: &Config, out: &mut Vec<Finding>) {
    101     if cfg.base_url_trimmed() == PLACEHOLDER_BASE_URL {
    102         out.push(Finding {
    103             severity: Severity::Warn,
    104             check: "base-url",
    105             message: format!(
    106                 "base_url이 아직 `{PLACEHOLDER_BASE_URL}` 입니다. \
    107                  canonical URL과 sitemap이 이 주소로 나갑니다."
    108             ),
    109             file: Some("sqzass.toml".into()),
    110         });
    111     }
    112 }
    113 
    114 /// 어떤 언어에는 있고 어떤 언어에는 없는 페이지. 빌드는 이걸 통과시킨다 —
    115 /// 미번역 페이지를 감추는 건 의도된 동작이다. 다만 감췄다는 사실은 알아야 한다.
    116 fn untranslated_pages(cfg: &Config, pages: &[Page], site: &Site, out: &mut Vec<Finding>) {
    117     if cfg.languages.len() < 2 {
    118         return;
    119     }
    120     let declared: BTreeSet<&str> = cfg.languages.keys().map(String::as_str).collect();
    121 
    122     let mut seen: BTreeSet<&str> = BTreeSet::new();
    123     for page in pages {
    124         if !seen.insert(&page.translation_key) {
    125             continue;
    126         }
    127         let have: BTreeSet<String> = site
    128             .language_set_of(page)
    129             .into_iter()
    130             .map(|(code, _)| code)
    131             .collect();
    132         let missing: Vec<&str> = declared
    133             .iter()
    134             .filter(|code| !have.contains(**code))
    135             .copied()
    136             .collect();
    137         if !missing.is_empty() {
    138             out.push(Finding {
    139                 severity: Severity::Warn,
    140                 check: "untranslated",
    141                 message: format!("{} 번역이 없습니다", missing.join(", ")),
    142                 file: Some(page.rel.to_string_lossy().into_owned()),
    143             });
    144         }
    145     }
    146 }
    147 
    148 fn missing_descriptions(pages: &[Page], out: &mut Vec<Finding>) {
    149     for page in pages {
    150         if page.front.description.trim().is_empty() {
    151             out.push(Finding {
    152                 severity: Severity::Note,
    153                 check: "description",
    154                 message: "description이 없습니다. 검색 결과와 <meta>에 쓰입니다.".into(),
    155                 file: Some(page.rel.to_string_lossy().into_owned()),
    156             });
    157         }
    158     }
    159 }
    160 
    161 /// 드래프트는 기본 빌드에서 빠지므로, 사이트에 없는 이유가 여기 있다.
    162 fn drafts(root: &Path, cfg: &Config, out: &mut Vec<Finding>) -> Result<()> {
    163     let all = crate::content::discover(root, cfg, true)?;
    164     for page in all.iter().filter(|p| p.front.draft) {
    165         out.push(Finding {
    166             severity: Severity::Note,
    167             check: "draft",
    168             message: "draft = true 이므로 빌드에 포함되지 않습니다".into(),
    169             file: Some(page.rel.to_string_lossy().into_owned()),
    170         });
    171     }
    172     Ok(())
    173 }
    174 
    175 /// 자식도 본문도 없는 섹션. 내비게이션에 들어가지만 눌러도 아무것도 없다.
    176 ///
    177 /// 자식이 없어도 `_index.md`에 본문이 있으면 경고하지 않는다 — 눌렀을 때
    178 /// 보이는 건 빈 목록이 아니라 그 본문이고, 단일 페이지 섹션은 정당한 구조다.
    179 /// 이 규칙이 잡으려는 건 만들다 만 디렉터리다.
    180 fn empty_sections(cfg: &Config, site: &Site, out: &mut Vec<Finding>) {
    181     for lang in cfg.languages.keys() {
    182         let mut stack: Vec<&crate::site::Section> = site.sections(lang).iter().collect();
    183         while let Some(section) = stack.pop() {
    184             let index_has_body = section
    185                 .index
    186                 .map(|i| !site.pages[i].body.trim().is_empty())
    187                 .unwrap_or(false);
    188             if section.pages.is_empty() && section.subsections.is_empty() && !index_has_body {
    189                 out.push(Finding {
    190                     severity: Severity::Warn,
    191                     check: "empty-section",
    192                     message: format!("'{}' 섹션에 페이지도 본문도 없습니다", section.title),
    193                     file: Some(section.url.clone()),
    194                 });
    195             }
    196             stack.extend(section.subsections.iter());
    197         }
    198     }
    199 }
    200 
    201 /// 어떤 페이지도 고르지 않은 템플릿. 이름을 바꾸고 참조를 안 고쳤을 때 남는다.
    202 fn unused_templates(
    203     root: &Path,
    204     pages: &[Page],
    205     site: &Site,
    206     out: &mut Vec<Finding>,
    207 ) -> Result<()> {
    208     let templates = Templates::load(root)?;
    209     let mut used: BTreeSet<String> = BTreeSet::new();
    210     // 페이지가 고르는 게 아니라 빌드가 직접 렌더하는 템플릿. 이걸 빼면 린트가
    211     // 쓰이고 있는 파일을 지우라고 권하게 된다.
    212     used.insert(crate::emit::NOT_FOUND_PATH.to_string());
    213     for page in pages {
    214         if let Ok(name) = crate::select_template(page, site, &templates) {
    215             used.insert(name);
    216         }
    217     }
    218 
    219     // `include`/`extends`로 끌려 들어가는 것들은 페이지가 직접 고르지 않는다.
    220     // 그것까지 미사용이라고 하면 이 검사는 매번 틀린 말을 하게 된다.
    221     let referenced = referenced_templates(root, &templates)?;
    222 
    223     for name in templates.names() {
    224         if !used.contains(name) && !referenced.contains(name) {
    225             out.push(Finding {
    226                 severity: Severity::Note,
    227                 check: "unused-template",
    228                 message: "어떤 페이지도 이 템플릿을 고르지 않았습니다".into(),
    229                 file: Some(format!("templates/{name}")),
    230             });
    231         }
    232     }
    233     Ok(())
    234 }
    235 
    236 /// 다른 템플릿이 이름으로 언급하는 템플릿들.
    237 ///
    238 /// 문자열 포함 검사다. 정확한 파서가 아니지만, 여기서 틀리는 방향은 **미사용이라고
    239 /// 말하지 않는 쪽**이다 — 지우라고 잘못 권하는 것보다 조용한 게 낫다.
    240 fn referenced_templates(root: &Path, templates: &Templates) -> Result<BTreeSet<String>> {
    241     let dir = root.join(crate::render::TEMPLATE_DIR);
    242     let mut sources = BTreeMap::new();
    243     for name in templates.names() {
    244         if let Ok(text) = std::fs::read_to_string(dir.join(name)) {
    245             sources.insert(name.clone(), text);
    246         }
    247     }
    248 
    249     let mut out = BTreeSet::new();
    250     for name in templates.names() {
    251         let quoted = format!("\"{name}\"");
    252         if sources
    253             .iter()
    254             .any(|(owner, text)| owner != name && text.contains(&quoted))
    255         {
    256             out.insert(name.clone());
    257         }
    258     }
    259     Ok(out)
    260 }
    261 
    262 #[cfg(test)]
    263 mod tests {
    264     use super::*;
    265 
    266     #[test]
    267     fn severity_orders_warn_above_note() {
    268         assert!(Severity::Warn > Severity::Note);
    269     }
    270 
    271     #[test]
    272     fn severity_parses_and_rejects_nonsense() {
    273         assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
    274         assert!("loud".parse::<Severity>().is_err());
    275     }
    276 
    277     #[test]
    278     fn a_section_with_a_contentful_index_is_not_empty() {
    279         // 단일 페이지 섹션(자식 없음, _index에 본문 있음)은 정당한 구조다.
    280         // 본문까지 없는 섹션만 만들다 만 디렉터리로 잡는다.
    281         let dir = std::env::temp_dir().join(format!("sqzass-doctor-empty-{}", std::process::id()));
    282         let _ = std::fs::remove_dir_all(&dir);
    283         for (d, body) in [("full", "충실한 본문이 있다."), ("bare", "")] {
    284             let p = dir.join("content").join(d);
    285             std::fs::create_dir_all(&p).unwrap();
    286             std::fs::write(
    287                 p.join("_index.md"),
    288                 format!("+++\ntitle = \"{d}\"\n+++\n{body}"),
    289             )
    290             .unwrap();
    291         }
    292         let cfg: Config = toml::from_str(
    293             "title = \"t\"\nbase_url = \"https://x.dev\"\n[languages.en]\nname = \"English\"\n",
    294         )
    295         .unwrap();
    296         let pages = crate::content::discover(&dir, &cfg, false).unwrap();
    297         let site = Site::build(&pages, &cfg);
    298         let mut out = Vec::new();
    299         empty_sections(&cfg, &site, &mut out);
    300         let flagged: Vec<_> = out.iter().filter(|f| f.check == "empty-section").collect();
    301         assert_eq!(flagged.len(), 1, "{out:?}");
    302         assert_eq!(flagged[0].file.as_deref(), Some("/bare/"));
    303         let _ = std::fs::remove_dir_all(&dir);
    304     }
    305 
    306     #[test]
    307     fn placeholder_base_url_is_reported() {
    308         let cfg: Config =
    309             toml::from_str("title = \"t\"\nbase_url = \"https://example.com/\"\n").unwrap();
    310         let mut out = Vec::new();
    311         placeholder_base_url(&cfg, &mut out);
    312         assert_eq!(out.len(), 1, "{out:?}");
    313         assert_eq!(out[0].check, "base-url");
    314     }
    315 
    316     #[test]
    317     fn a_real_base_url_is_not_reported() {
    318         let cfg: Config =
    319             toml::from_str("title = \"t\"\nbase_url = \"https://sqzass.sqzer.com\"\n").unwrap();
    320         let mut out = Vec::new();
    321         placeholder_base_url(&cfg, &mut out);
    322         assert!(out.is_empty(), "{out:?}");
    323     }
    324 
    325     #[test]
    326     fn findings_render_with_check_and_file() {
    327         let f = Finding {
    328             severity: Severity::Warn,
    329             check: "untranslated",
    330             message: "ko 번역이 없습니다".into(),
    331             file: Some("start/x.md".into()),
    332         };
    333         assert_eq!(
    334             f.to_string(),
    335             "warn: [untranslated] ko 번역이 없습니다 (start/x.md)"
    336         );
    337     }
    338 
    339     /// doctor의 exit code는 빌드 실패의 코드들과 겹치면 안 된다. 겹치면 CI가
    340     /// "지적이 있었다"와 "빌드가 깨졌다"를 구분할 수 없다.
    341     #[test]
    342     fn findings_exit_code_is_distinct_from_error_kinds() {
    343         use crate::error::Kind;
    344         for k in [
    345             Kind::Config,
    346             Kind::Content,
    347             Kind::Template,
    348             Kind::Io,
    349             Kind::Other,
    350         ] {
    351             assert_ne!(k.code(), FINDINGS_EXIT_CODE);
    352         }
    353     }
    354 }