sqzass.git

sqzass

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

src/content.rs (17209B)

      1 //! 콘텐츠 발견과 front matter 파싱.
      2 //!
      3 //! front matter는 TOML(`+++`)이 정본이다. `serde_yaml`이 아카이브된 상태라
      4 //! YAML을 정본으로 삼으면 살아있는 파서를 고르는 문제부터 떠안게 된다.
      5 
      6 use crate::config::Config;
      7 use anyhow::{Context, Result, bail};
      8 use serde::Deserialize;
      9 use std::path::{Path, PathBuf};
     10 use walkdir::WalkDir;
     11 
     12 pub const CONTENT_DIR: &str = "content";
     13 const FENCE: &str = "+++";
     14 
     15 #[derive(Debug, Clone, Default, Deserialize)]
     16 #[serde(default, deny_unknown_fields)]
     17 pub struct FrontMatter {
     18     /// 유일한 필수 필드. 없으면 좋은 에러를 내기 위해 Option으로 받는다.
     19     pub title: Option<String>,
     20     pub description: String,
     21     pub weight: i64,
     22     pub draft: bool,
     23     /// 발행 날짜. `sort_by = "date"`와 피드가 쓴다.
     24     /// TOML 날짜라 `2026-07-26` 도, `2026-07-26T09:30:00Z` 도 된다.
     25     pub date: Option<toml::value::Datetime>,
     26     /// 기본은 파일명 stem.
     27     pub slug: Option<String>,
     28     pub template: Option<String>,
     29     pub toc: bool,
     30     /// 기본은 파일명 stem 추론.
     31     pub translation_key: Option<String>,
     32     /// 이 페이지로 보낼 예전 URL들. 루트 절대 경로여야 한다.
     33     pub aliases: Vec<String>,
     34 
     35     // --- 섹션(`_index.md`) 전용 ---
     36     pub sort_by: Option<crate::config::SortBy>,
     37     pub page_template: Option<String>,
     38 
     39     #[serde(default)]
     40     pub extra: toml::Table,
     41 }
     42 
     43 #[derive(Debug, Clone)]
     44 pub struct Page {
     45     /// 소스 파일 절대 경로.
     46     pub source: PathBuf,
     47     /// `content/` 기준 상대 경로.
     48     pub rel: PathBuf,
     49     pub front: FrontMatter,
     50     pub title: String,
     51     /// front matter를 제거한 마크다운 본문.
     52     pub body: String,
     53     /// `body`의 첫 줄이 원본 파일에서 몇 번째 줄인지(1-based). 에러 보고에 쓴다.
     54     pub body_line_offset: usize,
     55     pub language: String,
     56     /// 번역 연결 키. 기본은 파일명 stem 추론.
     57     pub translation_key: String,
     58     /// `/`, `/about/`, `/ko/start/installation/`
     59     pub url: String,
     60     /// 출력 디렉터리 기준 상대 경로. `index.html`, `about/index.html`
     61     pub out_path: PathBuf,
     62     /// `_index.md` 여부 (섹션 인덱스).
     63     pub is_section: bool,
     64 }
     65 
     66 /// `content/` 아래의 모든 마크다운을 읽어 `Page`로 만든다.
     67 ///
     68 /// 드래프트는 `drafts`가 false면 제외한다.
     69 pub fn discover(root: &Path, cfg: &Config, drafts: bool) -> Result<Vec<Page>> {
     70     let content_root = root.join(CONTENT_DIR);
     71     if !content_root.is_dir() {
     72         bail!("{} 디렉터리가 없습니다", content_root.display());
     73     }
     74 
     75     let mut pages = Vec::new();
     76     for entry in WalkDir::new(&content_root).sort_by_file_name() {
     77         let entry = entry.context("content/ 순회 실패")?;
     78         if !entry.file_type().is_file() {
     79             continue;
     80         }
     81         let path = entry.path();
     82         if path.extension().and_then(|e| e.to_str()) != Some("md") {
     83             continue;
     84         }
     85 
     86         let page = parse_page(path, &content_root, cfg)?;
     87         if page.front.draft && !drafts {
     88             continue;
     89         }
     90         pages.push(page);
     91     }
     92 
     93     check_url_collisions(&pages)?;
     94     Ok(pages)
     95 }
     96 
     97 fn parse_page(path: &Path, content_root: &Path, cfg: &Config) -> Result<Page> {
     98     let raw = std::fs::read_to_string(path)
     99         .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
    100 
    101     let (fm_text, body, body_line_offset) = split_front_matter(&raw, path)?;
    102 
    103     // 빈 줄 하나를 앞에 붙여 toml이 세는 줄 번호를 원본 파일의 줄 번호에 맞춘다.
    104     // 여는 `+++`가 항상 1번 줄이므로 front matter 본문은 2번 줄부터 시작한다.
    105     // 이 한 줄이 없으면 에러가 가리키는 줄이 항상 하나씩 위를 가리킨다.
    106     let front: FrontMatter = toml::from_str(&format!("\n{fm_text}"))
    107         .with_context(|| format!("{}: front matter 파싱 실패", path.display()))?;
    108 
    109     // TOML은 `10:30:00` 같은 시각도 날짜 타입으로 받는다. 그걸 그대로 두면
    110     // 피드에도 정렬에도 쓰이지 못한 채 조용히 사라지고, 저자는 날짜를 적었다고
    111     // 믿는다. 조용히 버리는 대신 멈춘다.
    112     if let Some(dt) = &front.date
    113         && dt.date.is_none()
    114     {
    115         anyhow::bail!(
    116             "{}: date = {dt} 에 날짜가 없습니다 (시각만 있습니다).\n\
    117              `2026-07-26` 이나 `2026-07-26T09:30:00+09:00` 처럼 적으세요.",
    118             path.display()
    119         );
    120     }
    121 
    122     let title = front.title.clone().ok_or_else(|| {
    123         anyhow::anyhow!(
    124             "{}: front matter에 필수 필드 `title`이 없습니다",
    125             path.display()
    126         )
    127     })?;
    128 
    129     let rel = path
    130         .strip_prefix(content_root)
    131         .unwrap_or(path)
    132         .to_path_buf();
    133 
    134     let name = NameParts::parse(&rel, cfg);
    135     let slug = front.slug.clone().unwrap_or_else(|| name.stem.clone());
    136     let translation_key = front
    137         .translation_key
    138         .clone()
    139         .unwrap_or_else(|| name.translation_key.clone());
    140 
    141     let (url, out_path) = build_url(&name, &slug, cfg);
    142 
    143     Ok(Page {
    144         source: path.to_path_buf(),
    145         rel,
    146         front,
    147         title,
    148         body,
    149         body_line_offset,
    150         language: name.language,
    151         translation_key,
    152         url,
    153         out_path,
    154         is_section: name.is_section,
    155     })
    156 }
    157 
    158 /// 파일 경로에서 stem / 언어 / 섹션 여부를 뽑는다.
    159 ///
    160 /// `start/installation.md`    → stem "installation", lang = default
    161 /// `start/installation.ko.md` → stem "installation", lang = "ko"
    162 /// `start/_index.md`          → 섹션, stem "start"
    163 struct NameParts {
    164     /// URL의 마지막 세그먼트가 될 이름.
    165     stem: String,
    166     /// `content/` 기준 부모 디렉터리 세그먼트들.
    167     dirs: Vec<String>,
    168     language: String,
    169     is_section: bool,
    170     translation_key: String,
    171 }
    172 
    173 impl NameParts {
    174     fn parse(rel: &Path, cfg: &Config) -> Self {
    175         let dirs: Vec<String> = rel
    176             .parent()
    177             .map(|p| {
    178                 p.components()
    179                     .map(|c| c.as_os_str().to_string_lossy().into_owned())
    180                     .collect()
    181             })
    182             .unwrap_or_default();
    183 
    184         // `foo.ko.md` → file_stem은 "foo.ko" 이므로 한 번 더 벗긴다.
    185         let full_stem = rel
    186             .file_stem()
    187             .map(|s| s.to_string_lossy().into_owned())
    188             .unwrap_or_default();
    189 
    190         let (base, language) = match full_stem.rsplit_once('.') {
    191             // 마지막 조각이 설정에 선언된 언어 코드일 때만 언어로 인정한다.
    192             // 그렇지 않으면 `my.file.md` 같은 이름이 언어로 오인된다.
    193             Some((base, maybe_lang)) if cfg.languages.contains_key(maybe_lang) => {
    194                 (base.to_string(), maybe_lang.to_string())
    195             }
    196             _ => (full_stem.clone(), cfg.default_language.clone()),
    197         };
    198 
    199         let is_section = base == "_index";
    200         // 섹션의 이름은 자기 디렉터리 이름이다. 루트 섹션이면 빈 문자열.
    201         let stem = if is_section {
    202             dirs.last().cloned().unwrap_or_default()
    203         } else {
    204             base.clone()
    205         };
    206 
    207         // 번역 키는 언어를 뗀 경로. `start/installation`, `start/_index`
    208         let mut key_parts = dirs.clone();
    209         key_parts.push(base);
    210         let translation_key = key_parts.join("/");
    211 
    212         Self {
    213             stem,
    214             dirs,
    215             language,
    216             is_section,
    217             translation_key,
    218         }
    219     }
    220 }
    221 
    222 /// URL과 출력 경로를 만든다. 항상 `path/index.html` 형태로 뽑는다 —
    223 /// GitHub Pages처럼 rewrite 규칙이 없는 호스트에서 예쁜 URL을 내는 유일한 방법이다.
    224 fn build_url(name: &NameParts, slug: &str, cfg: &Config) -> (String, PathBuf) {
    225     let mut segments: Vec<String> = Vec::new();
    226 
    227     // 기본 언어는 루트, 나머지는 `/<code>/` 아래.
    228     if name.language != cfg.default_language {
    229         segments.push(name.language.clone());
    230     }
    231 
    232     if name.is_section {
    233         // 섹션의 URL은 자기 디렉터리 경로 그 자체다.
    234         segments.extend(name.dirs.iter().cloned());
    235     } else {
    236         segments.extend(name.dirs.iter().cloned());
    237         segments.push(slug.to_string());
    238     }
    239 
    240     // URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
    241     // 루트가 곧 출력 디렉터리이고, 서브경로는 그 루트가 도메인 어디에 놓이는지의
    242     // 문제이기 때문이다.
    243     let base = cfg.base_path();
    244     let url = if segments.is_empty() {
    245         format!("{base}/")
    246     } else {
    247         format!("{base}/{}/", segments.join("/"))
    248     };
    249 
    250     let mut out = PathBuf::new();
    251     for s in &segments {
    252         out.push(s);
    253     }
    254     out.push("index.html");
    255 
    256     (url, out)
    257 }
    258 
    259 /// 두 페이지가 같은 URL을 주장하면 하드 에러. 조용히 덮어쓰면 어느 쪽이 남는지가
    260 /// 파일 순회 순서에 의존하게 되고, 그건 재현 불가능한 버그가 된다.
    261 fn check_url_collisions(pages: &[Page]) -> Result<()> {
    262     use std::collections::HashMap;
    263     let mut seen: HashMap<&str, &Page> = HashMap::new();
    264     for page in pages {
    265         if let Some(prev) = seen.insert(&page.url, page) {
    266             bail!(
    267                 "URL 충돌: '{}' 을(를) 두 파일이 주장합니다\n  {}\n  {}\n\
    268                  front matter의 `slug`로 한쪽을 바꾸세요.",
    269                 page.url,
    270                 prev.source.display(),
    271                 page.source.display()
    272             );
    273         }
    274     }
    275     Ok(())
    276 }
    277 
    278 /// `+++` 펜스로 front matter를 분리한다.
    279 ///
    280 /// 직접 자르는 이유: 파싱 에러를 **원본 파일의 실제 줄 번호**로 보고하려면 front matter가
    281 /// 몇 줄을 먹었는지 알아야 하는데, 대부분의 크레이트가 그 정보를 잃는다.
    282 fn split_front_matter(raw: &str, path: &Path) -> Result<(String, String, usize)> {
    283     let text = raw.strip_prefix('\u{feff}').unwrap_or(raw);
    284     let mut lines = text.lines();
    285 
    286     let Some(first) = lines.next() else {
    287         bail!("{}: 파일이 비어 있습니다", path.display());
    288     };
    289 
    290     if first.trim_end() != FENCE {
    291         if first.trim_end() == "---" {
    292             bail!(
    293                 "{}: front matter가 `---`로 시작합니다. sqzass는 TOML front matter를 쓰므로 \
    294                  `+++`로 감싸주세요.",
    295                 path.display()
    296             );
    297         }
    298         bail!(
    299             "{}: 파일이 `{}` front matter로 시작해야 합니다",
    300             path.display(),
    301             FENCE
    302         );
    303     }
    304 
    305     let mut fm = String::new();
    306     let mut closed_at = None;
    307     // 1번 줄은 여는 펜스이므로 본문 후보는 2번 줄부터.
    308     for (idx, line) in lines.enumerate() {
    309         if line.trim_end() == FENCE {
    310             closed_at = Some(idx + 2); // 닫는 펜스의 1-based 줄 번호
    311             break;
    312         }
    313         fm.push_str(line);
    314         fm.push('\n');
    315     }
    316 
    317     let Some(closed_at) = closed_at else {
    318         bail!(
    319             "{}: front matter를 닫는 `{}`를 찾지 못했습니다",
    320             path.display(),
    321             FENCE
    322         );
    323     };
    324 
    325     // 닫는 펜스 다음 줄부터가 본문.
    326     let body: String = text.lines().skip(closed_at).collect::<Vec<_>>().join("\n");
    327 
    328     Ok((fm, body, closed_at + 1))
    329 }
    330 
    331 #[cfg(test)]
    332 mod tests {
    333     use super::*;
    334 
    335     fn cfg() -> Config {
    336         toml::from_str(
    337             r#"
    338             title = "t"
    339             base_url = "https://example.com"
    340             default_language = "en"
    341             [languages.en]
    342             name = "English"
    343             [languages.ko]
    344             name = "한국어"
    345             "#,
    346         )
    347         .unwrap()
    348     }
    349 
    350     /// front matter 오타도 조용히 무시하지 않는다. 그리고 에러가 가리키는 줄은
    351     /// **원본 파일의** 줄이어야 한다 — 펜스를 손으로 자른 이유가 그것이다.
    352     /// URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
    353     /// 루트가 곧 출력 디렉터리이기 때문이다.
    354     #[test]
    355     fn base_path_reaches_urls_but_not_output_paths() {
    356         let cfg: Config =
    357             toml::from_str("title = \"t\"\nbase_url = \"https://user.github.io/repo\"\n").unwrap();
    358 
    359         let parts = NameParts::parse(Path::new("start/install.md"), &cfg);
    360         let (url, out) = build_url(&parts, &parts.stem, &cfg);
    361         assert_eq!(url, "/repo/start/install/");
    362         assert_eq!(out, Path::new("start/install/index.html"));
    363 
    364         let root = NameParts::parse(Path::new("_index.md"), &cfg);
    365         let (url, out) = build_url(&root, &root.stem, &cfg);
    366         assert_eq!(url, "/repo/");
    367         assert_eq!(out, Path::new("index.html"));
    368     }
    369 
    370     #[test]
    371     fn unknown_front_matter_key_errors_at_the_real_line() {
    372         let dir = std::env::temp_dir().join(format!("sqzass-fm-{}", std::process::id()));
    373         let _ = std::fs::remove_dir_all(&dir);
    374         std::fs::create_dir_all(dir.join("content")).unwrap();
    375         let path = dir.join("content/_index.md");
    376         std::fs::write(&path, "+++\ntitle = \"x\"\nweigth = 10\n+++\n\nbody\n").unwrap();
    377 
    378         let cfg: Config =
    379             toml::from_str("title = \"t\"\nbase_url = \"https://example.com\"\n").unwrap();
    380         // anyhow는 `{}`에 최상위 컨텍스트만 낸다. 원인 사슬은 `{:#}`이라야 보인다.
    381         let err = format!(
    382             "{:#}",
    383             parse_page(&path, &dir.join("content"), &cfg).unwrap_err()
    384         );
    385         let _ = std::fs::remove_dir_all(&dir);
    386 
    387         assert!(err.contains("weigth"), "실제: {err}");
    388         assert!(err.contains("weight"), "후보를 제시해야 한다: {err}");
    389         // `weigth`는 파일의 3번 줄이다. 펜스를 세지 않으면 2번 줄이라고 말한다.
    390         assert!(err.contains("line 3"), "원본 파일 줄 번호가 아니다: {err}");
    391     }
    392 
    393     #[test]
    394     fn splits_front_matter_and_tracks_line_offset() {
    395         let raw = "+++\ntitle = \"Hi\"\n+++\n\n# Body\n";
    396         let (fm, body, offset) = split_front_matter(raw, Path::new("x.md")).unwrap();
    397         assert_eq!(fm.trim(), "title = \"Hi\"");
    398         assert_eq!(body, "\n# Body");
    399         // `+++`(1) `title`(2) `+++`(3) → 본문은 4번째 줄부터
    400         assert_eq!(offset, 4);
    401     }
    402 
    403     #[test]
    404     fn rejects_yaml_fence_with_a_useful_message() {
    405         let raw = "---\ntitle: Hi\n---\n";
    406         let err = split_front_matter(raw, Path::new("x.md"))
    407             .unwrap_err()
    408             .to_string();
    409         assert!(err.contains("+++"), "실제 메시지: {err}");
    410     }
    411 
    412     #[test]
    413     fn rejects_unterminated_front_matter() {
    414         let raw = "+++\ntitle = \"Hi\"\n\n# Body\n";
    415         assert!(split_front_matter(raw, Path::new("x.md")).is_err());
    416     }
    417 
    418     #[test]
    419     fn url_for_root_index() {
    420         let cfg = cfg();
    421         let parts = NameParts::parse(Path::new("_index.md"), &cfg);
    422         let (url, out) = build_url(&parts, &parts.stem, &cfg);
    423         assert_eq!(url, "/");
    424         assert_eq!(out, PathBuf::from("index.html"));
    425     }
    426 
    427     #[test]
    428     fn url_for_nested_page() {
    429         let cfg = cfg();
    430         let parts = NameParts::parse(Path::new("start/installation.md"), &cfg);
    431         let (url, out) = build_url(&parts, &parts.stem, &cfg);
    432         assert_eq!(url, "/start/installation/");
    433         assert_eq!(out, PathBuf::from("start/installation/index.html"));
    434     }
    435 
    436     #[test]
    437     fn url_for_section_index() {
    438         let cfg = cfg();
    439         let parts = NameParts::parse(Path::new("start/_index.md"), &cfg);
    440         assert!(parts.is_section);
    441         let (url, out) = build_url(&parts, &parts.stem, &cfg);
    442         assert_eq!(url, "/start/");
    443         assert_eq!(out, PathBuf::from("start/index.html"));
    444     }
    445 
    446     #[test]
    447     fn korean_pages_go_under_language_prefix() {
    448         let cfg = cfg();
    449         let parts = NameParts::parse(Path::new("start/installation.ko.md"), &cfg);
    450         assert_eq!(parts.language, "ko");
    451         assert_eq!(parts.stem, "installation");
    452         // 번역 키는 언어를 뗀 경로여서 영어판과 짝이 맞는다
    453         assert_eq!(parts.translation_key, "start/installation");
    454         let (url, out) = build_url(&parts, &parts.stem, &cfg);
    455         assert_eq!(url, "/ko/start/installation/");
    456         assert_eq!(out, PathBuf::from("ko/start/installation/index.html"));
    457     }
    458 
    459     #[test]
    460     fn translation_keys_match_across_languages() {
    461         let cfg = cfg();
    462         let en = NameParts::parse(Path::new("start/installation.md"), &cfg);
    463         let ko = NameParts::parse(Path::new("start/installation.ko.md"), &cfg);
    464         assert_eq!(en.translation_key, ko.translation_key);
    465     }
    466 
    467     #[test]
    468     fn unknown_dotted_suffix_is_not_treated_as_a_language() {
    469         let cfg = cfg();
    470         // "backup"은 선언된 언어가 아니므로 파일명 일부로 남아야 한다
    471         let parts = NameParts::parse(Path::new("notes.backup.md"), &cfg);
    472         assert_eq!(parts.language, "en");
    473         assert_eq!(parts.stem, "notes.backup");
    474     }
    475 }