sqzass.git

sqzass

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

src/site.rs (26585B)

      1 //! 페이지 목록을 섹션 트리로 조립하고, 언어별 내비게이션과 번역 연결을 만든다.
      2 //!
      3 //! 트리는 **언어별로 따로** 만든다. 미번역 페이지는 해당 언어 트리에 아예 존재하지
      4 //! 않으므로, 사이드바에 죽은 링크가 생기지 않는다.
      5 
      6 use crate::config::{Config, SortBy};
      7 use crate::content::Page;
      8 use serde::Serialize;
      9 use std::collections::BTreeMap;
     10 
     11 /// 언어별 섹션 트리 + 번역 색인.
     12 pub struct Site<'a> {
     13     pub cfg: &'a Config,
     14     pub pages: &'a [Page],
     15     /// 언어 코드 → 최상위 섹션들
     16     trees: BTreeMap<String, Vec<Section>>,
     17     /// translation_key → (언어 코드 → 페이지 인덱스)
     18     translations: BTreeMap<String, BTreeMap<String, usize>>,
     19     /// 페이지 URL → 소속 섹션 안에서의 순번 (정렬 후).
     20     ///
     21     /// `neighbours_of`가 페이지마다 섹션 목록을 URL 비교로 훑으면 평평한 대형
     22     /// 섹션에서 O(k²)가 된다 — 한 번 만들어 두면 조회다. 키가 URL인 근거:
     23     /// URL 충돌은 discover 단계의 하드 에러라 이 시점에 URL은 전역 유일하다.
     24     pos_in_section: BTreeMap<String, usize>,
     25 }
     26 
     27 /// 내부 트리 노드. 페이지는 인덱스로 참조해 소유권 문제를 피한다.
     28 #[derive(Debug, Clone)]
     29 pub struct Section {
     30     pub title: String,
     31     pub description: String,
     32     pub url: String,
     33     pub weight: i64,
     34     /// `content/` 기준 디렉터리 경로 (언어 접두사 제외)
     35     pub path: Vec<String>,
     36     /// 이 섹션의 `_index.md`
     37     pub index: Option<usize>,
     38     /// 직속 자식 페이지 (정렬됨)
     39     pub pages: Vec<usize>,
     40     pub subsections: Vec<Section>,
     41     /// `_index.md`의 `page_template` — 자식 페이지의 기본 템플릿
     42     pub page_template: Option<String>,
     43 }
     44 
     45 impl<'a> Site<'a> {
     46     pub fn build(pages: &'a [Page], cfg: &'a Config) -> Self {
     47         let mut translations: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
     48         for (i, p) in pages.iter().enumerate() {
     49             translations
     50                 .entry(p.translation_key.clone())
     51                 .or_default()
     52                 .insert(p.language.clone(), i);
     53         }
     54 
     55         let mut by_lang: BTreeMap<String, Vec<usize>> = BTreeMap::new();
     56         for (i, p) in pages.iter().enumerate() {
     57             by_lang.entry(p.language.clone()).or_default().push(i);
     58         }
     59 
     60         let trees: BTreeMap<String, Vec<Section>> = by_lang
     61             .into_iter()
     62             .map(|(lang, idxs)| (lang, build_tree(pages, &idxs, cfg)))
     63             .collect();
     64 
     65         let mut pos_in_section = BTreeMap::new();
     66         for sections in trees.values() {
     67             index_positions(sections, pages, &mut pos_in_section);
     68         }
     69 
     70         Self {
     71             cfg,
     72             pages,
     73             trees,
     74             translations,
     75             pos_in_section,
     76         }
     77     }
     78 
     79     /// 해당 언어의 최상위 섹션들.
     80     pub fn sections(&self, language: &str) -> &[Section] {
     81         self.trees
     82             .get(language)
     83             .map(|v| v.as_slice())
     84             .unwrap_or(&[])
     85     }
     86 
     87     /// 페이지가 속한 섹션. 최상위 페이지는 소속 섹션이 없다.
     88     fn section_of(&self, page: &Page) -> Option<&Section> {
     89         let dirs: Vec<String> = page
     90             .rel
     91             .parent()?
     92             .components()
     93             .map(|c| c.as_os_str().to_string_lossy().into_owned())
     94             .collect();
     95         if dirs.is_empty() {
     96             return None;
     97         }
     98         find_section(self.sections(&page.language), &dirs)
     99     }
    100 
    101     /// 페이지가 속한 섹션의 `page_template`. 템플릿 선택 단계에서 쓴다.
    102     pub fn page_template_for(&self, page: &Page) -> Option<&str> {
    103         self.section_of(page)?.page_template.as_deref()
    104     }
    105 
    106     /// 페이지가 속한 섹션을 참조 형태로. 브레드크럼과 검색 결과의 갈래 라벨이 쓴다.
    107     pub fn section_ref_of(&self, page: &Page) -> Option<PageRefCtx> {
    108         let s = self.section_of(page)?;
    109         Some(PageRefCtx {
    110             title: s.title.clone(),
    111             description: s.description.clone(),
    112             url: s.url.clone(),
    113             weight: s.weight,
    114         })
    115     }
    116 
    117     /// `@/` 링크 해석용 표. translation_key → (언어 → URL)
    118     pub fn link_index(&self) -> crate::links::LinkIndex {
    119         self.translations
    120             .iter()
    121             .map(|(key, by_lang)| {
    122                 let urls = by_lang
    123                     .iter()
    124                     .map(|(lang, &i)| (lang.clone(), self.pages[i].url.clone()))
    125                     .collect();
    126                 (key.clone(), urls)
    127             })
    128             .collect()
    129     }
    130 
    131     /// 섹션 인덱스 페이지의 직속 자식들. 섹션 페이지가 자기 하위 목록을 보여줄 때 쓴다.
    132     pub fn children_of(&self, page: &Page) -> Vec<PageRefCtx> {
    133         if !page.is_section {
    134             return Vec::new();
    135         }
    136         let dirs: Vec<String> = page
    137             .rel
    138             .parent()
    139             .map(|d| {
    140                 d.components()
    141                     .map(|c| c.as_os_str().to_string_lossy().into_owned())
    142                     .collect()
    143             })
    144             .unwrap_or_default();
    145 
    146         let sections = self.sections(&page.language);
    147         // 루트 `_index.md`의 자식은 최상위 섹션들이다.
    148         let Some(node) = (if dirs.is_empty() {
    149             None
    150         } else {
    151             find_section(sections, &dirs)
    152         }) else {
    153             return sections
    154                 .iter()
    155                 .map(|s| PageRefCtx {
    156                     title: s.title.clone(),
    157                     description: s.description.clone(),
    158                     url: s.url.clone(),
    159                     weight: s.weight,
    160                 })
    161                 .collect();
    162         };
    163 
    164         let mut out: Vec<PageRefCtx> = node
    165             .pages
    166             .iter()
    167             .map(|&i| PageRefCtx {
    168                 title: self.pages[i].title.clone(),
    169                 description: self.pages[i].front.description.clone(),
    170                 url: self.pages[i].url.clone(),
    171                 weight: self.pages[i].front.weight,
    172             })
    173             .collect();
    174         out.extend(node.subsections.iter().map(|s| PageRefCtx {
    175             title: s.title.clone(),
    176             description: s.description.clone(),
    177             url: s.url.clone(),
    178             weight: s.weight,
    179         }));
    180         out
    181     }
    182 
    183     /// 같은 섹션 안에서의 (이전, 다음) 페이지. 섹션이 정한 순서를 그대로 따른다.
    184     ///
    185     /// 섹션 인덱스에는 이웃이 없다 — 섹션은 자기 자식들과 같은 줄에 서 있지 않다.
    186     pub fn neighbours_of(&self, page: &Page) -> (Option<PageRefCtx>, Option<PageRefCtx>) {
    187         if page.is_section {
    188             return (None, None);
    189         }
    190         let Some(section) = self.section_of(page) else {
    191             return (None, None);
    192         };
    193         let Some(&at) = self.pos_in_section.get(&page.url) else {
    194             return (None, None);
    195         };
    196 
    197         let make = |i: usize| {
    198             let p = &self.pages[section.pages[i]];
    199             PageRefCtx {
    200                 title: p.title.clone(),
    201                 description: p.front.description.clone(),
    202                 url: p.url.clone(),
    203                 weight: p.front.weight,
    204             }
    205         };
    206         let prev = at.checked_sub(1).map(make);
    207         let next = (at + 1 < section.pages.len()).then(|| make(at + 1));
    208         (prev, next)
    209     }
    210 
    211     /// 이 페이지가 존재하는 **모든** 언어판, 자기 자신 포함. `(언어 코드, URL)`.
    212     ///
    213     /// `translations_of`와 달리 자기 자신을 뺀 목록이 아니다. sitemap의 hreflang
    214     /// 세트는 각 URL이 자기 자신까지 포함해 전부를 나열해야 유효하다.
    215     pub fn language_set_of(&self, page: &Page) -> Vec<(String, String)> {
    216         let Some(by_lang) = self.translations.get(&page.translation_key) else {
    217             return Vec::new();
    218         };
    219         let mut out: Vec<(String, String, i64)> = by_lang
    220             .iter()
    221             .map(|(code, &idx)| {
    222                 let weight = self.cfg.languages.get(code).map_or(0, |l| l.weight);
    223                 (code.clone(), self.pages[idx].url.clone(), weight)
    224             })
    225             .collect();
    226         out.sort_by(|a, b| (a.2, &a.0).cmp(&(b.2, &b.0)));
    227         out.into_iter().map(|(c, u, _)| (c, u)).collect()
    228     }
    229 
    230     /// 이 페이지의 다른 언어판. **번역이 실제로 있는 것만** 담는다.
    231     pub fn translations_of(&self, page: &Page) -> Vec<LanguageLink> {
    232         let Some(by_lang) = self.translations.get(&page.translation_key) else {
    233             return Vec::new();
    234         };
    235         let mut out: Vec<LanguageLink> = by_lang
    236             .iter()
    237             .filter(|(code, _)| code.as_str() != page.language)
    238             .filter_map(|(code, &idx)| {
    239                 let lang = self.cfg.languages.get(code)?;
    240                 Some(LanguageLink {
    241                     code: code.clone(),
    242                     name: lang.name.clone(),
    243                     url: self.pages[idx].url.clone(),
    244                     weight: lang.weight,
    245                 })
    246             })
    247             .collect();
    248         out.sort_by_key(|l| (l.weight, l.code.clone()));
    249         out
    250     }
    251 }
    252 
    253 #[derive(Debug, Clone, Serialize)]
    254 pub struct LanguageLink {
    255     pub code: String,
    256     pub name: String,
    257     pub url: String,
    258     #[serde(skip)]
    259     pub weight: i64,
    260 }
    261 
    262 /// 한 언어의 페이지들로 섹션 트리를 만든다.
    263 fn build_tree(pages: &[Page], idxs: &[usize], cfg: &Config) -> Vec<Section> {
    264     // 디렉터리 경로 → 섹션. 루트는 빈 경로.
    265     let mut root = Section {
    266         title: String::new(),
    267         description: String::new(),
    268         url: "/".into(),
    269         weight: 0,
    270         path: Vec::new(),
    271         index: None,
    272         pages: Vec::new(),
    273         subsections: Vec::new(),
    274         page_template: None,
    275     };
    276 
    277     // 1) 섹션 노드부터 만든다 (`_index.md` 기준)
    278     for &i in idxs {
    279         let p = &pages[i];
    280         if !p.is_section {
    281             continue;
    282         }
    283         let dirs = dirs_of(p);
    284         let node = ensure_section(&mut root, &dirs);
    285         node.index = Some(i);
    286         node.title = p.title.clone();
    287         node.description = p.front.description.clone();
    288         node.url = p.url.clone();
    289         node.weight = p.front.weight;
    290         node.page_template = p.front.page_template.clone();
    291     }
    292 
    293     // 2) 일반 페이지를 붙인다. `_index.md`가 없는 디렉터리도 섹션으로 승격시킨다 —
    294     //    없으면 페이지가 트리에서 사라져 사이드바에 안 나온다.
    295     for &i in idxs {
    296         let p = &pages[i];
    297         if p.is_section {
    298             continue;
    299         }
    300         let dirs = dirs_of(p);
    301         let node = ensure_section(&mut root, &dirs);
    302         node.pages.push(i);
    303     }
    304 
    305     sort_section(&mut root, pages, cfg);
    306     root.subsections
    307 }
    308 
    309 /// `content/` 기준 디렉터리 세그먼트.
    310 fn dirs_of(p: &Page) -> Vec<String> {
    311     p.rel
    312         .parent()
    313         .map(|d| {
    314             d.components()
    315                 .map(|c| c.as_os_str().to_string_lossy().into_owned())
    316                 .collect()
    317         })
    318         .unwrap_or_default()
    319 }
    320 
    321 /// 경로를 따라 내려가며 없으면 만든다.
    322 fn ensure_section<'s>(root: &'s mut Section, dirs: &[String]) -> &'s mut Section {
    323     let mut cur = root;
    324     for (depth, seg) in dirs.iter().enumerate() {
    325         let pos = cur
    326             .subsections
    327             .iter()
    328             .position(|s| s.path.last() == Some(seg));
    329         let pos = match pos {
    330             Some(p) => p,
    331             None => {
    332                 let mut path = cur.path.clone();
    333                 path.push(seg.clone());
    334                 cur.subsections.push(Section {
    335                     // `_index.md`가 없으면 디렉터리 이름을 제목으로 쓴다.
    336                     title: seg.clone(),
    337                     description: String::new(),
    338                     url: format!("/{}/", path.join("/")),
    339                     weight: 0,
    340                     path,
    341                     index: None,
    342                     pages: Vec::new(),
    343                     subsections: Vec::new(),
    344                     page_template: None,
    345                 });
    346                 let _ = depth;
    347                 cur.subsections.len() - 1
    348             }
    349         };
    350         cur = &mut cur.subsections[pos];
    351     }
    352     cur
    353 }
    354 
    355 /// 정렬이 끝난 트리에서 페이지 URL → 섹션 내 순번을 한 번에 적는다.
    356 fn index_positions(sections: &[Section], pages: &[Page], out: &mut BTreeMap<String, usize>) {
    357     for s in sections {
    358         for (pos, &i) in s.pages.iter().enumerate() {
    359             out.insert(pages[i].url.clone(), pos);
    360         }
    361         index_positions(&s.subsections, pages, out);
    362     }
    363 }
    364 
    365 fn find_section<'s>(sections: &'s [Section], dirs: &[String]) -> Option<&'s Section> {
    366     let (first, rest) = dirs.split_first()?;
    367     let node = sections.iter().find(|s| s.path.last() == Some(first))?;
    368     if rest.is_empty() {
    369         Some(node)
    370     } else {
    371         find_section(&node.subsections, rest)
    372     }
    373 }
    374 
    375 /// 섹션 자신의 `sort_by`가 있으면 그걸, 없으면 `[nav] sort_by`를 쓴다.
    376 fn sort_section(sec: &mut Section, pages: &[Page], cfg: &Config) {
    377     let sort_by = sec
    378         .index
    379         .and_then(|i| pages[i].front.sort_by)
    380         .unwrap_or(cfg.nav.sort_by);
    381 
    382     // 날짜순은 **내림차순**이고 날짜 없는 페이지는 뒤로 간다. 정렬 키를 뒤집는
    383     // 대신 없는 날짜를 0으로 두면 그것들이 맨 앞으로 올라온다.
    384     let date_key = |i: &usize| -> (std::cmp::Reverse<i64>, String) {
    385         let p = &pages[*i];
    386         let k = p
    387             .front
    388             .date
    389             .as_ref()
    390             .and_then(crate::feed::PageDate::from_toml)
    391             .map_or(0, |d| d.sort_key());
    392         (std::cmp::Reverse(k), p.title.clone())
    393     };
    394 
    395     match sort_by {
    396         SortBy::Date => sec.pages.sort_by_key(date_key),
    397         _ => sec.pages.sort_by_key(|i: &usize| {
    398             let p = &pages[*i];
    399             match sort_by {
    400                 SortBy::Title => (0, p.title.clone()),
    401                 _ => (p.front.weight, p.title.clone()),
    402             }
    403         }),
    404     }
    405 
    406     // 하위 섹션에는 날짜가 없다. 날짜순 섹션에서도 제목순으로 둔다.
    407     sec.subsections.sort_by_key(|s| match sort_by {
    408         SortBy::Weight => (s.weight, s.title.clone()),
    409         _ => (0, s.title.clone()),
    410     });
    411 
    412     for sub in &mut sec.subsections {
    413         sort_section(sub, pages, cfg);
    414     }
    415 }
    416 
    417 // --- 템플릿 컨텍스트 ---
    418 
    419 #[derive(Debug, Clone, Serialize)]
    420 pub struct SectionCtx {
    421     pub title: String,
    422     pub description: String,
    423     pub url: String,
    424     pub weight: i64,
    425     pub pages: Vec<PageRefCtx>,
    426     pub subsections: Vec<SectionCtx>,
    427 }
    428 
    429 #[derive(Debug, Clone, Serialize)]
    430 pub struct PageRefCtx {
    431     pub title: String,
    432     pub description: String,
    433     pub url: String,
    434     pub weight: i64,
    435 }
    436 
    437 pub fn section_ctx(sections: &[Section], pages: &[Page]) -> Vec<SectionCtx> {
    438     sections
    439         .iter()
    440         .map(|s| SectionCtx {
    441             title: s.title.clone(),
    442             description: s.description.clone(),
    443             url: s.url.clone(),
    444             weight: s.weight,
    445             pages: s
    446                 .pages
    447                 .iter()
    448                 .map(|&i| PageRefCtx {
    449                     title: pages[i].title.clone(),
    450                     description: pages[i].front.description.clone(),
    451                     url: pages[i].url.clone(),
    452                     weight: pages[i].front.weight,
    453                 })
    454                 .collect(),
    455             subsections: section_ctx(&s.subsections, pages),
    456         })
    457         .collect()
    458 }
    459 
    460 #[cfg(test)]
    461 mod tests {
    462     use super::*;
    463     use crate::content;
    464     use std::path::{Path, PathBuf};
    465 
    466     fn cfg() -> Config {
    467         toml::from_str(
    468             r#"
    469             title = "t"
    470             base_url = "https://example.com"
    471             default_language = "en"
    472             [languages.en]
    473             name = "English"
    474             weight = 1
    475             [languages.ko]
    476             name = "한국어"
    477             weight = 2
    478             "#,
    479         )
    480         .unwrap()
    481     }
    482 
    483     /// 임시 사이트를 만들고 discover까지 돌린다.
    484     struct Fixture(PathBuf);
    485 
    486     impl Fixture {
    487         fn new(name: &str, files: &[(&str, &str)]) -> Self {
    488             let root =
    489                 std::env::temp_dir().join(format!("sqzass-site-{}-{name}", std::process::id()));
    490             let _ = std::fs::remove_dir_all(&root);
    491             for (rel, body) in files {
    492                 let path = root.join("content").join(rel);
    493                 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    494                 std::fs::write(path, body).unwrap();
    495             }
    496             Self(root)
    497         }
    498         fn pages(&self, cfg: &Config) -> Vec<Page> {
    499             content::discover(&self.0, cfg, false).unwrap()
    500         }
    501     }
    502 
    503     impl Drop for Fixture {
    504         fn drop(&mut self) {
    505             let _ = std::fs::remove_dir_all(&self.0);
    506         }
    507     }
    508 
    509     fn fm(title: &str, extra: &str) -> String {
    510         format!("+++\ntitle = \"{title}\"\n{extra}+++\n\nbody\n")
    511     }
    512 
    513     /// weight와 title은 오름차순인데 date만 내림차순이다. 날짜순 목록에서
    514     /// 사람이 기대하는 건 최신 글이고, 날짜 없는 글은 뒤로 가야 한다.
    515     #[test]
    516     fn date_sorting_is_newest_first_and_undated_last() {
    517         let cfg = cfg();
    518         let f = Fixture::new(
    519             "dates",
    520             &[
    521                 ("_index.md", &fm("Home", "")),
    522                 ("posts/_index.md", &fm("Posts", "sort_by = \"date\"\n")),
    523                 ("posts/a.md", &fm("A", "date = 2026-01-15\n")),
    524                 ("posts/b.md", &fm("B", "date = 2026-09-01\n")),
    525                 ("posts/c.md", &fm("C", "date = 2026-05-20\n")),
    526                 ("posts/none.md", &fm("None", "")),
    527             ],
    528         );
    529         let pages = f.pages(&cfg);
    530         let site = Site::build(&pages, &cfg);
    531         let posts = &site.sections("en")[0];
    532         let titles: Vec<&str> = posts
    533             .pages
    534             .iter()
    535             .map(|&i| pages[i].title.as_str())
    536             .collect();
    537         assert_eq!(titles, vec!["B", "C", "A", "None"], "실제: {titles:?}");
    538     }
    539 
    540     #[test]
    541     fn neighbours_follow_the_section_order() {
    542         let cfg = cfg();
    543         let f = Fixture::new(
    544             "neighbours",
    545             &[
    546                 ("_index.md", &fm("Home", "")),
    547                 ("start/_index.md", &fm("Getting started", "weight = 10\n")),
    548                 ("start/a.md", &fm("A", "weight = 1\n")),
    549                 ("start/b.md", &fm("B", "weight = 2\n")),
    550                 ("start/c.md", &fm("C", "weight = 3\n")),
    551             ],
    552         );
    553         let pages = f.pages(&cfg);
    554         let site = Site::build(&pages, &cfg);
    555         let by_title = |t: &str| pages.iter().find(|p| p.title == t).unwrap();
    556 
    557         let (prev, next) = site.neighbours_of(by_title("B"));
    558         assert_eq!(prev.map(|p| p.title), Some("A".into()));
    559         assert_eq!(next.map(|p| p.title), Some("C".into()));
    560 
    561         // 양 끝은 한쪽만 있다. 다른 섹션으로 넘어가지 않는다.
    562         let (prev, next) = site.neighbours_of(by_title("A"));
    563         assert!(prev.is_none(), "첫 페이지에 이전이 있다");
    564         assert_eq!(next.map(|p| p.title), Some("B".into()));
    565         let (prev, next) = site.neighbours_of(by_title("C"));
    566         assert_eq!(prev.map(|p| p.title), Some("B".into()));
    567         assert!(next.is_none(), "마지막 페이지에 다음이 있다");
    568 
    569         // 섹션 인덱스는 자식들과 같은 줄에 서 있지 않다.
    570         let (prev, next) = site.neighbours_of(by_title("Getting started"));
    571         assert!(prev.is_none() && next.is_none());
    572     }
    573 
    574     #[test]
    575     fn language_set_includes_the_page_itself() {
    576         let cfg = cfg();
    577         let f = Fixture::new(
    578             "langset",
    579             &[
    580                 ("_index.md", &fm("Home", "")),
    581                 ("_index.ko.md", &fm("홈", "")),
    582                 ("solo.md", &fm("Solo", "")),
    583             ],
    584         );
    585         let pages = f.pages(&cfg);
    586         let site = Site::build(&pages, &cfg);
    587         let by_title = |t: &str| pages.iter().find(|p| p.title == t).unwrap();
    588 
    589         // sitemap의 hreflang 세트는 자기 자신까지 나열해야 유효하다.
    590         let set = site.language_set_of(by_title("Home"));
    591         assert_eq!(
    592             set,
    593             vec![
    594                 ("en".to_string(), "/".to_string()),
    595                 ("ko".to_string(), "/ko/".to_string())
    596             ]
    597         );
    598         assert_eq!(site.language_set_of(by_title("Solo")).len(), 1);
    599     }
    600 
    601     #[test]
    602     fn builds_nested_sections_per_language() {
    603         let cfg = cfg();
    604         let f = Fixture::new(
    605             "tree",
    606             &[
    607                 ("_index.md", &fm("Home", "")),
    608                 ("_index.ko.md", &fm("홈", "")),
    609                 ("start/_index.md", &fm("Getting started", "weight = 10\n")),
    610                 ("start/_index.ko.md", &fm("시작하기", "weight = 10\n")),
    611                 ("start/install.md", &fm("Install", "weight = 1\n")),
    612                 ("start/install.ko.md", &fm("설치", "weight = 1\n")),
    613                 ("start/first.md", &fm("First site", "weight = 2\n")),
    614             ],
    615         );
    616         let pages = f.pages(&cfg);
    617         let site = Site::build(&pages, &cfg);
    618 
    619         let en = site.sections("en");
    620         assert_eq!(en.len(), 1, "최상위 섹션은 start 하나여야 한다");
    621         assert_eq!(en[0].title, "Getting started");
    622         assert_eq!(en[0].url, "/start/");
    623         assert_eq!(en[0].pages.len(), 2);
    624 
    625         let ko = site.sections("ko");
    626         assert_eq!(ko.len(), 1);
    627         assert_eq!(ko[0].title, "시작하기");
    628         assert_eq!(ko[0].url, "/ko/start/");
    629         // 한국어판은 install만 번역돼 있다
    630         assert_eq!(ko[0].pages.len(), 1, "미번역 페이지가 트리에 새어들어왔다");
    631     }
    632 
    633     #[test]
    634     fn sorts_pages_by_weight_then_title() {
    635         let cfg = cfg();
    636         let f = Fixture::new(
    637             "sort",
    638             &[
    639                 ("docs/_index.md", &fm("Docs", "")),
    640                 ("docs/c.md", &fm("C", "weight = 1\n")),
    641                 ("docs/a.md", &fm("A", "weight = 3\n")),
    642                 ("docs/b.md", &fm("B", "weight = 2\n")),
    643             ],
    644         );
    645         let pages = f.pages(&cfg);
    646         let site = Site::build(&pages, &cfg);
    647         let titles: Vec<&str> = site.sections("en")[0]
    648             .pages
    649             .iter()
    650             .map(|&i| pages[i].title.as_str())
    651             .collect();
    652         assert_eq!(titles, vec!["C", "B", "A"]);
    653     }
    654 
    655     #[test]
    656     fn section_sort_by_overrides_global() {
    657         let cfg = cfg();
    658         let f = Fixture::new(
    659             "sortoverride",
    660             &[
    661                 ("docs/_index.md", &fm("Docs", "sort_by = \"title\"\n")),
    662                 ("docs/c.md", &fm("C", "weight = 1\n")),
    663                 ("docs/a.md", &fm("A", "weight = 3\n")),
    664                 ("docs/b.md", &fm("B", "weight = 2\n")),
    665             ],
    666         );
    667         let pages = f.pages(&cfg);
    668         let site = Site::build(&pages, &cfg);
    669         let titles: Vec<&str> = site.sections("en")[0]
    670             .pages
    671             .iter()
    672             .map(|&i| pages[i].title.as_str())
    673             .collect();
    674         assert_eq!(
    675             titles,
    676             vec!["A", "B", "C"],
    677             "섹션의 sort_by가 전역을 못 이겼다"
    678         );
    679     }
    680 
    681     #[test]
    682     fn directory_without_index_still_appears() {
    683         // `_index.md`를 깜빡해도 페이지가 사이드바에서 사라지면 안 된다.
    684         let cfg = cfg();
    685         let f = Fixture::new("noindex", &[("guides/howto.md", &fm("How to", ""))]);
    686         let pages = f.pages(&cfg);
    687         let site = Site::build(&pages, &cfg);
    688         let en = site.sections("en");
    689         assert_eq!(en.len(), 1);
    690         assert_eq!(
    691             en[0].title, "guides",
    692             "디렉터리 이름이 제목 대체값이어야 한다"
    693         );
    694         assert_eq!(en[0].pages.len(), 1);
    695     }
    696 
    697     #[test]
    698     fn translations_only_include_existing_ones() {
    699         let cfg = cfg();
    700         let f = Fixture::new(
    701             "trans",
    702             &[
    703                 ("a.md", &fm("A", "")),
    704                 ("a.ko.md", &fm("가", "")),
    705                 ("b.md", &fm("B", "")), // 번역 없음
    706             ],
    707         );
    708         let pages = f.pages(&cfg);
    709         let site = Site::build(&pages, &cfg);
    710 
    711         let a = pages.iter().find(|p| p.title == "A").unwrap();
    712         let t = site.translations_of(a);
    713         assert_eq!(t.len(), 1);
    714         assert_eq!(t[0].code, "ko");
    715         assert_eq!(t[0].url, "/ko/a/");
    716         assert_eq!(t[0].name, "한국어");
    717 
    718         let b = pages.iter().find(|p| p.title == "B").unwrap();
    719         assert!(
    720             site.translations_of(b).is_empty(),
    721             "번역이 없는데 언어 링크가 생겼다"
    722         );
    723     }
    724 
    725     #[test]
    726     fn page_template_comes_from_parent_section() {
    727         let cfg = cfg();
    728         let f = Fixture::new(
    729             "ptmpl",
    730             &[
    731                 (
    732                     "docs/_index.md",
    733                     &fm("Docs", "page_template = \"doc.html\"\n"),
    734                 ),
    735                 ("docs/x.md", &fm("X", "")),
    736                 ("other.md", &fm("Other", "")),
    737             ],
    738         );
    739         let pages = f.pages(&cfg);
    740         let site = Site::build(&pages, &cfg);
    741         let x = pages.iter().find(|p| p.title == "X").unwrap();
    742         assert_eq!(site.page_template_for(x), Some("doc.html"));
    743         let o = pages.iter().find(|p| p.title == "Other").unwrap();
    744         assert_eq!(site.page_template_for(o), None);
    745     }
    746 
    747     #[test]
    748     fn anchorizer_passes_hangul_through() {
    749         // 계획 단계에서 "착수 즉시 확인"으로 표시했던 항목. 한글이 버려지면
    750         // 모든 한국어 제목의 id가 충돌해 TOC와 anchor가 붕괴한다.
    751         let mut a = comrak::Anchorizer::new();
    752         assert_eq!(a.anchorize("설치"), "설치");
    753         assert_eq!(a.anchorize("정적 사이트 생성기"), "정적-사이트-생성기");
    754         assert_eq!(
    755             a.anchorize("설치"),
    756             "설치-1",
    757             "중복 제목 dedupe가 동작해야 한다"
    758         );
    759         assert_eq!(
    760             a.anchorize("한글 제목 with English"),
    761             "한글-제목-with-english"
    762         );
    763         let _ = Path::new("");
    764     }
    765 }