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 }