diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -1,17 +1,31 @@
+{% from "partials/sidebar.html" import nav %}
<!doctype html>
<html lang="{{ page.language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{% if page.url == "/" %}{{ site.title }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
+<title>{% if page.url == "/" or page.url == "/" ~ page.language ~ "/" %}{{ site.title }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
{% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
<link rel="canonical" href="{{ page.permalink }}">
+{%- for t in page.translations %}
+<link rel="alternate" hreflang="{{ t.code }}" href="{{ site.base_url }}{{ t.url }}">
+{%- endfor %}
</head>
<body>
+<a href="#content" class="skip">Skip to content</a>
+
{% include "partials/header.html" %}
-<main>
+
+<div class="layout">
+ <aside class="sidebar" aria-label="Documentation">
+ {{ nav(site.sections, page.url) }}
+ </aside>
+
+ <main id="content">
{% block content %}{% endblock %}
-</main>
+ </main>
+</div>
+
{% include "partials/footer.html" %}
</body>
</html>
diff --git a/docs/templates/partials/header.html b/docs/templates/partials/header.html
@@ -1,3 +1,4 @@
<header>
- <a href="{{ site.base_url }}/">{{ site.title }}</a>
+ <a href="{% if page.language == "en" %}/{% else %}/{{ page.language }}/{% endif %}" class="brand">{{ site.title }}</a>
+ {% include "partials/lang-switch.html" %}
</header>
diff --git a/docs/templates/partials/lang-switch.html b/docs/templates/partials/lang-switch.html
@@ -0,0 +1,9 @@
+{# page.translations는 번역이 실제로 있는 언어만 담는다. 비어 있으면 아무것도 그리지
+ 않는다 — 없는 번역으로 보내는 링크를 만들지 않기 위해서다. #}
+{%- if page.translations %}
+<nav aria-label="Language">
+ {%- for t in page.translations %}
+ <a href="{{ t.url }}" lang="{{ t.code }}" hreflang="{{ t.code }}">{{ t.name }}</a>
+ {%- endfor %}
+</nav>
+{%- endif %}
diff --git a/docs/templates/partials/sidebar.html b/docs/templates/partials/sidebar.html
@@ -0,0 +1,19 @@
+{# 섹션 트리를 재귀적으로 그린다. site.sections는 현재 언어의 것만 담겨 있으므로
+ 미번역 페이지는 애초에 여기 없다 — 죽은 링크가 생길 수 없다. #}
+{% macro nav(sections, current) %}
+<ul>
+ {%- for s in sections %}
+ <li>
+ <a href="{{ s.url }}"{% if s.url == current %} aria-current="page"{% endif %}>{{ s.title }}</a>
+ {%- if s.pages or s.subsections %}
+ <ul>
+ {%- for p in s.pages %}
+ <li><a href="{{ p.url }}"{% if p.url == current %} aria-current="page"{% endif %}>{{ p.title }}</a></li>
+ {%- endfor %}
+ {%- if s.subsections %}{{ nav(s.subsections, current) }}{% endif %}
+ </ul>
+ {%- endif %}
+ </li>
+ {%- endfor %}
+</ul>
+{% endmacro %}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod config;
pub mod content;
pub mod markdown;
pub mod render;
+pub mod site;
use anyhow::{Context, Result};
use minijinja::context;
@@ -12,6 +13,7 @@ use std::path::{Path, PathBuf};
use config::Config;
use content::Page;
use render::{PageCtx, SiteCtx, Templates};
+use site::Site;
#[derive(Debug, Clone)]
pub struct BuildOptions {
@@ -41,6 +43,7 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
let out_dir = resolve_output_dir(opts, &cfg);
let pages = content::discover(root, &cfg, drafts)?;
+ let site = Site::build(&pages, &cfg);
let templates = Templates::load(root)?;
let md = markdown::Renderer::new(&cfg.markdown);
@@ -52,7 +55,7 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
let mut written = 0usize;
for page in &pages {
- let html = render_page(page, &cfg, &templates, &md)?;
+ let html = render_page(page, &site, &cfg, &templates, &md)?;
let dest = out_dir.join(&page.out_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
@@ -84,17 +87,19 @@ fn resolve_output_dir(opts: &BuildOptions, cfg: &Config) -> PathBuf {
fn render_page(
page: &Page,
+ site: &Site,
cfg: &Config,
templates: &Templates,
md: &markdown::Renderer,
) -> Result<String> {
- let template = select_template(page, templates)?;
+ let template = select_template(page, site, templates)?;
- let site = SiteCtx {
+ let site_ctx = SiteCtx {
title: cfg.title.clone(),
description: cfg.description.clone(),
base_url: cfg.base_url_trimmed().to_string(),
language: page.language.clone(),
+ sections: site::section_ctx(site.sections(&page.language), site.pages),
};
let page_ctx = PageCtx {
@@ -107,19 +112,27 @@ fn render_page(
draft: page.front.draft,
toc: page.front.toc,
language: page.language.clone(),
+ // 번역이 실제로 있는 언어만 담긴다 — 템플릿은 이게 비었는지로
+ // 언어 전환 UI를 보일지 결정하면 된다.
+ translations: site.translations_of(page),
};
templates
- .render(&template, context! { site => site, page => page_ctx })
+ .render(&template, context! { site => site_ctx, page => page_ctx })
.with_context(|| format!("{} 렌더 중", page.source.display()))
}
-/// 템플릿 선택은 **명시적**이다: front matter `template` → 섹션이면 `section.html`
-/// → `page.html`. Hugo의 20단계 lookup order 캐스케이드는 채택하지 않는다 —
-/// 사용자가 가장 많이 헤매는 지점이기 때문이다.
+/// 템플릿 선택은 **명시적**이다. 순서는 딱 넷:
///
-/// (부모 섹션의 `page_template`을 보는 단계는 섹션 트리가 생기는 M1에서 들어간다.)
-fn select_template(page: &Page, templates: &Templates) -> Result<String> {
+/// 1. front matter `template`
+/// 2. 섹션이면 `section.html`
+/// 3. 부모 섹션의 `page_template`
+/// 4. `page.html`
+///
+/// Hugo의 20단계 lookup order 캐스케이드는 채택하지 않는다 — kind × section × type ×
+/// layout × language × output format을 곱해 만든 순서표는 사용자가 가장 많이 헤매는
+/// 지점이고, "해석 순서를 출력이라도 해달라"는 요청이 10년째 열려 있다.
+fn select_template(page: &Page, site: &Site, templates: &Templates) -> Result<String> {
if let Some(explicit) = &page.front.template {
if templates.has(explicit) {
return Ok(explicit.clone());
@@ -133,6 +146,17 @@ fn select_template(page: &Page, templates: &Templates) -> Result<String> {
);
}
+ if page.is_section && templates.has("section.html") {
+ return Ok("section.html".into());
+ }
+
+ if !page.is_section
+ && let Some(pt) = site.page_template_for(page)
+ && templates.has(pt)
+ {
+ return Ok(pt.to_string());
+ }
+
let candidate = if page.is_section {
"section.html"
} else {
diff --git a/src/render.rs b/src/render.rs
@@ -1,7 +1,7 @@
//! minijinja 템플릿 환경과 렌더 컨텍스트.
use anyhow::{Context, Result, bail};
-use minijinja::{Environment, UndefinedBehavior, Value};
+use minijinja::{AutoEscape, Environment, Output, State, UndefinedBehavior, Value};
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
@@ -88,6 +88,7 @@ impl Templates {
// 만드는데, front matter의 선택적 필드를 존재 검사하는 게 SSG 템플릿의
// 일상이라 쓸 수 없다. SemiStrict는 undefined의 출력/순회/속성 접근만 막는다.
env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
+ env.set_formatter(format_value);
Ok(Self { env, names })
}
@@ -110,24 +111,58 @@ impl Templates {
}
}
-// ⚠️ 알려진 결함 (M1에서 수정): minijinja의 HTML 이스케이퍼는 `/`를 `/`로 바꾼다
-// (utils.rs의 `b'/' => "/"`, OWASP식 보수적 이스케이핑). `v_htmlescape` 피처로
-// 바꿔봤지만 그쪽도 `/`를 이스케이프하므로 우회가 안 된다.
-//
-// 결과: 모든 URL이 `href="https://..."` 로 나간다. 유효한 HTML이고 브라우저와
-// 제대로 된 파서는 디코드하지만, 출력물이 지저분하다.
-//
-// 제대로 된 해법은 URL을 일반 텍스트 이스케이퍼에 태우지 않는 것이다. URL 속성값에
-// 필요한 이스케이프는 `& " < >` 넷뿐이다. 컨텍스트의 URL 필드를 String이 아니라
-// `Value::from_safe_string(직접_이스케이프한_값)`으로 넘기면 된다 — 다만 그러려면
-// 컨텍스트를 Serialize 구조체가 아니라 Value로 조립해야 하므로, 내부 링크를 루트
-// 절대화하는 M1 작업과 함께 처리한다.
+/// HTML 이스케이프. minijinja 기본 이스케이퍼와 딱 한 곳이 다르다: **`/`를 건드리지
+/// 않는다.**
+///
+/// minijinja는 `/`를 `/`로 바꾼다(`utils.rs`의 `b'/' => "/"`). OWASP식
+/// belt-and-braces 조치인데, 노리는 건 `</script>` 탈출이고 그건 `<`를 이스케이프하는
+/// 것으로 이미 막힌다. 대가는 사이트의 **모든 URL**이 `href="https://…"`로
+/// 나가는 것이다. 유효한 HTML이고 브라우저는 디코드하지만 출력물을 읽을 수 없게 만든다.
+///
+/// 참고로 **Jinja2 본가의 `escape()`도 `/`를 이스케이프하지 않는다.** 이 포매터는
+/// minijinja를 원본 Jinja2 의미론 쪽으로 되돌리는 것이다.
+///
+/// (`v_htmlescape` 피처도 시도했으나 그쪽 역시 `/`를 이스케이프해서 우회가 안 됐다.)
+fn write_html_escaped(out: &mut Output, s: &str) -> Result<(), minijinja::Error> {
+ let mut last = 0;
+ for (i, ch) in s.char_indices() {
+ let repl = match ch {
+ '&' => "&",
+ '<' => "<",
+ '>' => ">",
+ '"' => """,
+ '\'' => "'",
+ _ => continue,
+ };
+ out.write_str(&s[last..i])?;
+ out.write_str(repl)?;
+ last = i + ch.len_utf8();
+ }
+ out.write_str(&s[last..])?;
+ Ok(())
+}
+
+fn format_value(out: &mut Output, state: &State, value: &Value) -> Result<(), minijinja::Error> {
+ // 이미 safe로 표시된 값과 HTML이 아닌 이스케이프 모드(.txt, .json)는
+ // 기본 동작에 맡긴다. 문자열이 아닌 값도 마찬가지 — 이스케이프할 게 없다.
+ if state.auto_escape() == AutoEscape::Html
+ && !value.is_safe()
+ && let Some(s) = value.as_str()
+ {
+ return write_html_escaped(out, s);
+ }
+ minijinja::escape_formatter(out, state, value)
+}
+
#[derive(Debug, Clone, Serialize)]
pub struct SiteCtx {
pub title: String,
pub description: String,
pub base_url: String,
+ /// 지금 렌더 중인 페이지의 언어.
pub language: String,
+ /// 이 언어의 최상위 섹션들. 사이드바는 여기서 만든다.
+ pub sections: Vec<crate::site::SectionCtx>,
}
#[derive(Debug, Clone, Serialize)]
@@ -142,6 +177,8 @@ pub struct PageCtx {
pub draft: bool,
pub toc: bool,
pub language: String,
+ /// 다른 언어판. **번역이 실제로 있을 때만** 채워진다.
+ pub translations: Vec<crate::site::LanguageLink>,
}
#[cfg(test)]
@@ -229,6 +266,53 @@ mod tests {
assert!(!out.contains("<script>"), "이스케이프되지 않았다: {out}");
}
+ #[test]
+ fn urls_keep_their_slashes() {
+ // minijinja 기본 이스케이퍼는 `/`를 `/`로 바꿔서 사이트의 모든 링크를
+ // 읽을 수 없게 만든다. 커스텀 포매터로 되돌린 것을 고정한다.
+ let site = Site::new("slash", &[("page.html", r#"<a href="{{ page.url }}">x</a>"#)]);
+ let out = site
+ .templates()
+ .render(
+ "page.html",
+ context! { page => context!{ url => "https://sqzass.sqzer.com/ko/start/" } },
+ )
+ .unwrap();
+ assert_eq!(out, r#"<a href="https://sqzass.sqzer.com/ko/start/">x</a>"#);
+ }
+
+ #[test]
+ fn dangerous_characters_are_still_escaped() {
+ // `/`만 빼고 나머지는 그대로 이스케이프되어야 한다. 이게 깨지면 XSS다.
+ let site = Site::new("danger", &[("page.html", "{{ page.t }}")]);
+ let t = site.templates();
+ for (input, expected) in [
+ ("<script>", "<script>"),
+ ("a & b", "a & b"),
+ ("say \"hi\"", "say "hi""),
+ ("it's", "it's"),
+ ("</script><img onerror=x>", "</script><img onerror=x>"),
+ ] {
+ let out = t
+ .render("page.html", context! { page => context!{ t => input } })
+ .unwrap();
+ assert_eq!(out, expected, "입력: {input}");
+ }
+ }
+
+ #[test]
+ fn safe_values_bypass_the_custom_formatter() {
+ let site = Site::new("safebypass", &[("page.html", "{{ page.c | safe }}")]);
+ let out = site
+ .templates()
+ .render(
+ "page.html",
+ context! { page => context!{ c => "<p>a & b</p>" } },
+ )
+ .unwrap();
+ assert_eq!(out, "<p>a & b</p>");
+ }
+
#[test]
fn plain_text_templates_are_not_html_escaped() {
// robots.txt / llms.txt 같은 평문 출력은 이스케이프하면 안 된다.
diff --git a/src/site.rs b/src/site.rs
@@ -0,0 +1,482 @@
+//! 페이지 목록을 섹션 트리로 조립하고, 언어별 내비게이션과 번역 연결을 만든다.
+//!
+//! 트리는 **언어별로 따로** 만든다. 미번역 페이지는 해당 언어 트리에 아예 존재하지
+//! 않으므로, 사이드바에 죽은 링크가 생기지 않는다.
+
+use crate::config::{Config, SortBy};
+use crate::content::Page;
+use serde::Serialize;
+use std::collections::BTreeMap;
+
+/// 언어별 섹션 트리 + 번역 색인.
+pub struct Site<'a> {
+ pub cfg: &'a Config,
+ pub pages: &'a [Page],
+ /// 언어 코드 → 최상위 섹션들
+ trees: BTreeMap<String, Vec<Section>>,
+ /// translation_key → (언어 코드 → 페이지 인덱스)
+ translations: BTreeMap<String, BTreeMap<String, usize>>,
+}
+
+/// 내부 트리 노드. 페이지는 인덱스로 참조해 소유권 문제를 피한다.
+#[derive(Debug, Clone)]
+pub struct Section {
+ pub title: String,
+ pub description: String,
+ pub url: String,
+ pub weight: i64,
+ /// `content/` 기준 디렉터리 경로 (언어 접두사 제외)
+ pub path: Vec<String>,
+ /// 이 섹션의 `_index.md`
+ pub index: Option<usize>,
+ /// 직속 자식 페이지 (정렬됨)
+ pub pages: Vec<usize>,
+ pub subsections: Vec<Section>,
+ /// `_index.md`의 `page_template` — 자식 페이지의 기본 템플릿
+ pub page_template: Option<String>,
+}
+
+impl<'a> Site<'a> {
+ pub fn build(pages: &'a [Page], cfg: &'a Config) -> Self {
+ let mut translations: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
+ for (i, p) in pages.iter().enumerate() {
+ translations
+ .entry(p.translation_key.clone())
+ .or_default()
+ .insert(p.language.clone(), i);
+ }
+
+ let mut by_lang: BTreeMap<String, Vec<usize>> = BTreeMap::new();
+ for (i, p) in pages.iter().enumerate() {
+ by_lang.entry(p.language.clone()).or_default().push(i);
+ }
+
+ let trees = by_lang
+ .into_iter()
+ .map(|(lang, idxs)| (lang, build_tree(pages, &idxs, cfg)))
+ .collect();
+
+ Self { cfg, pages, trees, translations }
+ }
+
+ /// 해당 언어의 최상위 섹션들.
+ pub fn sections(&self, language: &str) -> &[Section] {
+ self.trees.get(language).map(|v| v.as_slice()).unwrap_or(&[])
+ }
+
+ /// 페이지가 속한 섹션의 `page_template`. 템플릿 선택 단계에서 쓴다.
+ pub fn page_template_for(&self, page: &Page) -> Option<&str> {
+ let dirs = page.rel.parent()?;
+ let dirs: Vec<String> = dirs
+ .components()
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
+ .collect();
+ if dirs.is_empty() {
+ return None;
+ }
+ find_section(self.sections(&page.language), &dirs)?
+ .page_template
+ .as_deref()
+ }
+
+ /// 이 페이지의 다른 언어판. **번역이 실제로 있는 것만** 담는다.
+ pub fn translations_of(&self, page: &Page) -> Vec<LanguageLink> {
+ let Some(by_lang) = self.translations.get(&page.translation_key) else {
+ return Vec::new();
+ };
+ let mut out: Vec<LanguageLink> = by_lang
+ .iter()
+ .filter(|(code, _)| code.as_str() != page.language)
+ .filter_map(|(code, &idx)| {
+ let lang = self.cfg.languages.get(code)?;
+ Some(LanguageLink {
+ code: code.clone(),
+ name: lang.name.clone(),
+ url: self.pages[idx].url.clone(),
+ weight: lang.weight,
+ })
+ })
+ .collect();
+ out.sort_by_key(|l| (l.weight, l.code.clone()));
+ out
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct LanguageLink {
+ pub code: String,
+ pub name: String,
+ pub url: String,
+ #[serde(skip)]
+ pub weight: i64,
+}
+
+/// 한 언어의 페이지들로 섹션 트리를 만든다.
+fn build_tree(pages: &[Page], idxs: &[usize], cfg: &Config) -> Vec<Section> {
+ // 디렉터리 경로 → 섹션. 루트는 빈 경로.
+ let mut root = Section {
+ title: String::new(),
+ description: String::new(),
+ url: "/".into(),
+ weight: 0,
+ path: Vec::new(),
+ index: None,
+ pages: Vec::new(),
+ subsections: Vec::new(),
+ page_template: None,
+ };
+
+ // 1) 섹션 노드부터 만든다 (`_index.md` 기준)
+ for &i in idxs {
+ let p = &pages[i];
+ if !p.is_section {
+ continue;
+ }
+ let dirs = dirs_of(p);
+ let node = ensure_section(&mut root, &dirs);
+ node.index = Some(i);
+ node.title = p.title.clone();
+ node.description = p.front.description.clone();
+ node.url = p.url.clone();
+ node.weight = p.front.weight;
+ node.page_template = p.front.page_template.clone();
+ }
+
+ // 2) 일반 페이지를 붙인다. `_index.md`가 없는 디렉터리도 섹션으로 승격시킨다 —
+ // 없으면 페이지가 트리에서 사라져 사이드바에 안 나온다.
+ for &i in idxs {
+ let p = &pages[i];
+ if p.is_section {
+ continue;
+ }
+ let dirs = dirs_of(p);
+ let node = ensure_section(&mut root, &dirs);
+ node.pages.push(i);
+ }
+
+ sort_section(&mut root, pages, cfg);
+ root.subsections
+}
+
+/// `content/` 기준 디렉터리 세그먼트.
+fn dirs_of(p: &Page) -> Vec<String> {
+ p.rel
+ .parent()
+ .map(|d| {
+ d.components()
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// 경로를 따라 내려가며 없으면 만든다.
+fn ensure_section<'s>(root: &'s mut Section, dirs: &[String]) -> &'s mut Section {
+ let mut cur = root;
+ for (depth, seg) in dirs.iter().enumerate() {
+ let pos = cur.subsections.iter().position(|s| s.path.last() == Some(seg));
+ let pos = match pos {
+ Some(p) => p,
+ None => {
+ let mut path = cur.path.clone();
+ path.push(seg.clone());
+ cur.subsections.push(Section {
+ // `_index.md`가 없으면 디렉터리 이름을 제목으로 쓴다.
+ title: seg.clone(),
+ description: String::new(),
+ url: format!("/{}/", path.join("/")),
+ weight: 0,
+ path,
+ index: None,
+ pages: Vec::new(),
+ subsections: Vec::new(),
+ page_template: None,
+ });
+ let _ = depth;
+ cur.subsections.len() - 1
+ }
+ };
+ cur = &mut cur.subsections[pos];
+ }
+ cur
+}
+
+fn find_section<'s>(sections: &'s [Section], dirs: &[String]) -> Option<&'s Section> {
+ let (first, rest) = dirs.split_first()?;
+ let node = sections.iter().find(|s| s.path.last() == Some(first))?;
+ if rest.is_empty() { Some(node) } else { find_section(&node.subsections, rest) }
+}
+
+/// 섹션 자신의 `sort_by`가 있으면 그걸, 없으면 `[nav] sort_by`를 쓴다.
+fn sort_section(sec: &mut Section, pages: &[Page], cfg: &Config) {
+ let sort_by = sec
+ .index
+ .and_then(|i| pages[i].front.sort_by)
+ .unwrap_or(cfg.nav.sort_by);
+
+ let key = |i: &usize| -> (i64, String) {
+ let p = &pages[*i];
+ match sort_by {
+ SortBy::Weight => (p.front.weight, p.title.clone()),
+ SortBy::Title => (0, p.title.clone()),
+ }
+ };
+ sec.pages.sort_by_key(key);
+
+ sec.subsections.sort_by_key(|s| match sort_by {
+ SortBy::Weight => (s.weight, s.title.clone()),
+ SortBy::Title => (0, s.title.clone()),
+ });
+
+ for sub in &mut sec.subsections {
+ sort_section(sub, pages, cfg);
+ }
+}
+
+// --- 템플릿 컨텍스트 ---
+
+#[derive(Debug, Clone, Serialize)]
+pub struct SectionCtx {
+ pub title: String,
+ pub description: String,
+ pub url: String,
+ pub weight: i64,
+ pub pages: Vec<PageRefCtx>,
+ pub subsections: Vec<SectionCtx>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct PageRefCtx {
+ pub title: String,
+ pub description: String,
+ pub url: String,
+ pub weight: i64,
+}
+
+pub fn section_ctx(sections: &[Section], pages: &[Page]) -> Vec<SectionCtx> {
+ sections
+ .iter()
+ .map(|s| SectionCtx {
+ title: s.title.clone(),
+ description: s.description.clone(),
+ url: s.url.clone(),
+ weight: s.weight,
+ pages: s
+ .pages
+ .iter()
+ .map(|&i| PageRefCtx {
+ title: pages[i].title.clone(),
+ description: pages[i].front.description.clone(),
+ url: pages[i].url.clone(),
+ weight: pages[i].front.weight,
+ })
+ .collect(),
+ subsections: section_ctx(&s.subsections, pages),
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::content;
+ use std::path::{Path, PathBuf};
+
+ fn cfg() -> Config {
+ toml::from_str(
+ r#"
+ title = "t"
+ base_url = "https://example.com"
+ default_language = "en"
+ [languages.en]
+ name = "English"
+ weight = 1
+ [languages.ko]
+ name = "한국어"
+ weight = 2
+ "#,
+ )
+ .unwrap()
+ }
+
+ /// 임시 사이트를 만들고 discover까지 돌린다.
+ struct Fixture(PathBuf);
+
+ impl Fixture {
+ fn new(name: &str, files: &[(&str, &str)]) -> Self {
+ let root =
+ std::env::temp_dir().join(format!("sqzass-site-{}-{name}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ for (rel, body) in files {
+ let path = root.join("content").join(rel);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, body).unwrap();
+ }
+ Self(root)
+ }
+ fn pages(&self, cfg: &Config) -> Vec<Page> {
+ content::discover(&self.0, cfg, false).unwrap()
+ }
+ }
+
+ impl Drop for Fixture {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+ }
+
+ fn fm(title: &str, extra: &str) -> String {
+ format!("+++\ntitle = \"{title}\"\n{extra}+++\n\nbody\n")
+ }
+
+ #[test]
+ fn builds_nested_sections_per_language() {
+ let cfg = cfg();
+ let f = Fixture::new(
+ "tree",
+ &[
+ ("_index.md", &fm("Home", "")),
+ ("_index.ko.md", &fm("홈", "")),
+ ("start/_index.md", &fm("Getting started", "weight = 10\n")),
+ ("start/_index.ko.md", &fm("시작하기", "weight = 10\n")),
+ ("start/install.md", &fm("Install", "weight = 1\n")),
+ ("start/install.ko.md", &fm("설치", "weight = 1\n")),
+ ("start/first.md", &fm("First site", "weight = 2\n")),
+ ],
+ );
+ let pages = f.pages(&cfg);
+ let site = Site::build(&pages, &cfg);
+
+ let en = site.sections("en");
+ assert_eq!(en.len(), 1, "최상위 섹션은 start 하나여야 한다");
+ assert_eq!(en[0].title, "Getting started");
+ assert_eq!(en[0].url, "/start/");
+ assert_eq!(en[0].pages.len(), 2);
+
+ let ko = site.sections("ko");
+ assert_eq!(ko.len(), 1);
+ assert_eq!(ko[0].title, "시작하기");
+ assert_eq!(ko[0].url, "/ko/start/");
+ // 한국어판은 install만 번역돼 있다
+ assert_eq!(ko[0].pages.len(), 1, "미번역 페이지가 트리에 새어들어왔다");
+ }
+
+ #[test]
+ fn sorts_pages_by_weight_then_title() {
+ let cfg = cfg();
+ let f = Fixture::new(
+ "sort",
+ &[
+ ("docs/_index.md", &fm("Docs", "")),
+ ("docs/c.md", &fm("C", "weight = 1\n")),
+ ("docs/a.md", &fm("A", "weight = 3\n")),
+ ("docs/b.md", &fm("B", "weight = 2\n")),
+ ],
+ );
+ let pages = f.pages(&cfg);
+ let site = Site::build(&pages, &cfg);
+ let titles: Vec<&str> = site.sections("en")[0]
+ .pages
+ .iter()
+ .map(|&i| pages[i].title.as_str())
+ .collect();
+ assert_eq!(titles, vec!["C", "B", "A"]);
+ }
+
+ #[test]
+ fn section_sort_by_overrides_global() {
+ let cfg = cfg();
+ let f = Fixture::new(
+ "sortoverride",
+ &[
+ ("docs/_index.md", &fm("Docs", "sort_by = \"title\"\n")),
+ ("docs/c.md", &fm("C", "weight = 1\n")),
+ ("docs/a.md", &fm("A", "weight = 3\n")),
+ ("docs/b.md", &fm("B", "weight = 2\n")),
+ ],
+ );
+ let pages = f.pages(&cfg);
+ let site = Site::build(&pages, &cfg);
+ let titles: Vec<&str> = site.sections("en")[0]
+ .pages
+ .iter()
+ .map(|&i| pages[i].title.as_str())
+ .collect();
+ assert_eq!(titles, vec!["A", "B", "C"], "섹션의 sort_by가 전역을 못 이겼다");
+ }
+
+ #[test]
+ fn directory_without_index_still_appears() {
+ // `_index.md`를 깜빡해도 페이지가 사이드바에서 사라지면 안 된다.
+ let cfg = cfg();
+ let f = Fixture::new(
+ "noindex",
+ &[("guides/howto.md", &fm("How to", ""))],
+ );
+ let pages = f.pages(&cfg);
+ let site = Site::build(&pages, &cfg);
+ let en = site.sections("en");
+ assert_eq!(en.len(), 1);
+ assert_eq!(en[0].title, "guides", "디렉터리 이름이 제목 대체값이어야 한다");
+ assert_eq!(en[0].pages.len(), 1);
+ }
+
+ #[test]
+ fn translations_only_include_existing_ones() {
+ let cfg = cfg();
+ let f = Fixture::new(
+ "trans",
+ &[
+ ("a.md", &fm("A", "")),
+ ("a.ko.md", &fm("가", "")),
+ ("b.md", &fm("B", "")), // 번역 없음
+ ],
+ );
+ let pages = f.pages(&cfg);
+ let site = Site::build(&pages, &cfg);
+
+ let a = pages.iter().find(|p| p.title == "A").unwrap();
+ let t = site.translations_of(a);
+ assert_eq!(t.len(), 1);
+ assert_eq!(t[0].code, "ko");
+ assert_eq!(t[0].url, "/ko/a/");
+ assert_eq!(t[0].name, "한국어");
+
+ let b = pages.iter().find(|p| p.title == "B").unwrap();
+ assert!(
+ site.translations_of(b).is_empty(),
+ "번역이 없는데 언어 링크가 생겼다"
+ );
+ }
+
+ #[test]
+ fn page_template_comes_from_parent_section() {
+ let cfg = cfg();
+ let f = Fixture::new(
+ "ptmpl",
+ &[
+ ("docs/_index.md", &fm("Docs", "page_template = \"doc.html\"\n")),
+ ("docs/x.md", &fm("X", "")),
+ ("other.md", &fm("Other", "")),
+ ],
+ );
+ let pages = f.pages(&cfg);
+ let site = Site::build(&pages, &cfg);
+ let x = pages.iter().find(|p| p.title == "X").unwrap();
+ assert_eq!(site.page_template_for(x), Some("doc.html"));
+ let o = pages.iter().find(|p| p.title == "Other").unwrap();
+ assert_eq!(site.page_template_for(o), None);
+ }
+
+ #[test]
+ fn anchorizer_passes_hangul_through() {
+ // 계획 단계에서 "착수 즉시 확인"으로 표시했던 항목. 한글이 버려지면
+ // 모든 한국어 제목의 id가 충돌해 TOC와 anchor가 붕괴한다.
+ let mut a = comrak::Anchorizer::new();
+ assert_eq!(a.anchorize("설치"), "설치");
+ assert_eq!(a.anchorize("정적 사이트 생성기"), "정적-사이트-생성기");
+ assert_eq!(a.anchorize("설치"), "설치-1", "중복 제목 dedupe가 동작해야 한다");
+ assert_eq!(a.anchorize("한글 제목 with English"), "한글-제목-with-english");
+ let _ = Path::new("");
+ }
+}
|