sqzass.git

sqzass

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

src/render.rs (19189B)

      1 //! minijinja 템플릿 환경과 렌더 컨텍스트.
      2 
      3 use anyhow::{Context, Result, bail};
      4 use minijinja::{AutoEscape, Environment, Output, State, UndefinedBehavior, Value};
      5 use serde::Serialize;
      6 use std::collections::HashMap;
      7 use std::path::Path;
      8 use std::sync::Arc;
      9 use walkdir::WalkDir;
     10 
     11 pub const TEMPLATE_DIR: &str = "templates";
     12 const TEMPLATE_EXTENSIONS: &[&str] = &["html", "xml", "txt", "json", "jinja", "j2"];
     13 
     14 pub struct Templates {
     15     env: Environment<'static>,
     16     names: Vec<String>,
     17 }
     18 
     19 impl Templates {
     20     /// `templates/` 전체를 **메모리로 한 번에 읽어** 그 스냅샷에서만 템플릿을 해석한다.
     21     ///
     22     /// 이게 중요한 이유: minijinja의 로더는 `{% include %}` / `{% extends %}`를
     23     /// VM 레벨에서 가로챈다. 로더가 디스크를 직접 읽게 두면, 파일을 저장하는 도중에
     24     /// 리빌드가 걸렸을 때 **반쯤 쓰인 파셜**을 읽어들일 수 있다. 스냅샷에서 해석하면
     25     /// 한 번의 빌드가 항상 하나의 일관된 템플릿 상태를 본다.
     26     pub fn load(root: &Path) -> Result<Self> {
     27         let dir = root.join(TEMPLATE_DIR);
     28         if !dir.is_dir() {
     29             bail!("{} 디렉터리가 없습니다", dir.display());
     30         }
     31 
     32         let mut snapshot: HashMap<String, String> = HashMap::new();
     33         for entry in WalkDir::new(&dir).sort_by_file_name() {
     34             let entry = entry.context("templates/ 순회 실패")?;
     35             if !entry.file_type().is_file() {
     36                 continue;
     37             }
     38             let path = entry.path();
     39             let is_template = path
     40                 .extension()
     41                 .and_then(|e| e.to_str())
     42                 .is_some_and(|e| TEMPLATE_EXTENSIONS.contains(&e));
     43             if !is_template {
     44                 continue;
     45             }
     46 
     47             // 템플릿 이름은 확장자를 포함한 상대 경로다: `base.html`, `partials/nav.html`.
     48             // Jinja 관례를 그대로 따르므로 `{% extends "base.html" %}`가 자연스럽다.
     49             let name = path
     50                 .strip_prefix(&dir)
     51                 .unwrap_or(path)
     52                 .to_string_lossy()
     53                 .replace('\\', "/");
     54             let source = std::fs::read_to_string(path)
     55                 .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
     56             snapshot.insert(name, source);
     57         }
     58 
     59         if snapshot.is_empty() {
     60             bail!("{}에 템플릿이 없습니다", dir.display());
     61         }
     62 
     63         let mut names: Vec<String> = snapshot.keys().cloned().collect();
     64         names.sort();
     65 
     66         let snapshot = Arc::new(snapshot);
     67         let mut env = Environment::new();
     68 
     69         {
     70             let snapshot = Arc::clone(&snapshot);
     71             env.set_loader(move |name| Ok(snapshot.get(name).cloned()));
     72         }
     73 
     74         // autoescape는 minijinja의 확장자 기반 기본 동작을 그대로 쓴다.
     75         // html/htm/xml → Html, json → Json, txt → None.
     76         //
     77         // "확장자가 없으면 AutoEscape::None이 되어 조용히 이스케이프가 꺼진다"는
     78         // 함정이 있지만, 우리 로더가 TEMPLATE_EXTENSIONS에 있는 파일만 받으므로
     79         // 확장자 없는 템플릿은 애초에 존재할 수 없다. 이름 없는 참조는 스냅샷에서
     80         // 못 찾아 명확한 에러가 된다 — 조용한 실패가 아니다.
     81         //
     82         // 여기서 None을 Html로 끌어올리면 안 된다. 우리 목록에서 None이 되는 건
     83         // `.txt`뿐인데, robots.txt / llms.txt를 HTML 이스케이프하면 `&`가 `&amp;`가
     84         // 되어 파일이 망가진다.
     85         env.set_auto_escape_callback(minijinja::default_auto_escape_callback);
     86 
     87         // Strict가 아니라 SemiStrict. Strict는 `{% if optional_field %}`까지 에러로
     88         // 만드는데, front matter의 선택적 필드를 존재 검사하는 게 SSG 템플릿의
     89         // 일상이라 쓸 수 없다. SemiStrict는 undefined의 출력/순회/속성 접근만 막는다.
     90         env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
     91         env.set_formatter(format_value);
     92 
     93         Ok(Self { env, names })
     94     }
     95 
     96     /// 템플릿에서 `{{ asset("css/main.css") }}`로 해시가 붙은 실제 URL을 얻게 한다.
     97     ///
     98     /// 없는 에셋은 **에러**다. 조용히 원래 경로를 돌려주면 오타가 404 링크로 배포되고,
     99     /// minijinja 에러가 템플릿 이름과 줄 번호를 달고 나오므로 찾기도 쉽다.
    100     pub fn with_assets(mut self, manifest: std::collections::BTreeMap<String, String>) -> Self {
    101         self.env.add_function("asset", move |path: String| {
    102             manifest
    103                 .get(path.trim_start_matches('/'))
    104                 .cloned()
    105                 .ok_or_else(|| {
    106                     let mut known: Vec<&str> = manifest.keys().map(|s| s.as_str()).collect();
    107                     known.sort_unstable();
    108                     minijinja::Error::new(
    109                         minijinja::ErrorKind::InvalidOperation,
    110                         format!(
    111                             "에셋 '{path}'이(가) 없습니다. 사용 가능: {}",
    112                             known.join(", ")
    113                         ),
    114                     )
    115                 })
    116         });
    117         self
    118     }
    119 
    120     /// 템플릿에서 `{{ t("skip_to_content") }}`로 UI 문자열을 얻게 한다.
    121     ///
    122     /// 언어는 인자로 받지 않고 렌더 중인 컨텍스트의 `page.language`에서 읽는다.
    123     /// 호출하는 쪽이 매번 언어를 넘겨야 하면 언젠가 한 군데를 빠뜨리고, 그 페이지만
    124     /// 다른 언어로 나간다.
    125     pub fn with_i18n(mut self, catalog: crate::i18n::Catalog) -> Self {
    126         self.env.add_function("t", move |state: &State, key: &str| {
    127             let language = state
    128                 .lookup("page")
    129                 .and_then(|p| p.get_attr("language").ok())
    130                 .map(|v| v.to_string())
    131                 .unwrap_or_default();
    132 
    133             crate::i18n::lookup(&catalog, &language, key)
    134                 .map_err(|msg| minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, msg))
    135         });
    136         self
    137     }
    138 
    139     pub fn has(&self, name: &str) -> bool {
    140         self.names.iter().any(|n| n == name)
    141     }
    142 
    143     pub fn names(&self) -> &[String] {
    144         &self.names
    145     }
    146 
    147     pub fn render(&self, name: &str, ctx: Value) -> Result<String> {
    148         let tmpl = self
    149             .env
    150             .get_template(name)
    151             .with_context(|| format!("템플릿 '{name}'을(를) 찾을 수 없습니다"))?;
    152         tmpl.render(ctx)
    153             .with_context(|| format!("템플릿 '{name}' 렌더 실패"))
    154     }
    155 }
    156 
    157 /// HTML 이스케이프. minijinja 기본 이스케이퍼와 딱 한 곳이 다르다: **`/`를 건드리지
    158 /// 않는다.**
    159 ///
    160 /// minijinja는 `/`를 `&#x2f;`로 바꾼다(`utils.rs`의 `b'/' => "&#x2f;"`). OWASP식
    161 /// belt-and-braces 조치인데, 노리는 건 `</script>` 탈출이고 그건 `<`를 이스케이프하는
    162 /// 것으로 이미 막힌다. 대가는 사이트의 **모든 URL**이 `href="https:&#x2f;&#x2f;…"`로
    163 /// 나가는 것이다. 유효한 HTML이고 브라우저는 디코드하지만 출력물을 읽을 수 없게 만든다.
    164 ///
    165 /// 참고로 **Jinja2 본가의 `escape()`도 `/`를 이스케이프하지 않는다.** 이 포매터는
    166 /// minijinja를 원본 Jinja2 의미론 쪽으로 되돌리는 것이다.
    167 ///
    168 /// (`v_htmlescape` 피처도 시도했으나 그쪽 역시 `/`를 이스케이프해서 우회가 안 됐다.)
    169 fn write_html_escaped(out: &mut Output, s: &str) -> Result<(), minijinja::Error> {
    170     let mut last = 0;
    171     for (i, ch) in s.char_indices() {
    172         let repl = match ch {
    173             '&' => "&amp;",
    174             '<' => "&lt;",
    175             '>' => "&gt;",
    176             '"' => "&quot;",
    177             '\'' => "&#x27;",
    178             _ => continue,
    179         };
    180         out.write_str(&s[last..i])?;
    181         out.write_str(repl)?;
    182         last = i + ch.len_utf8();
    183     }
    184     out.write_str(&s[last..])?;
    185     Ok(())
    186 }
    187 
    188 fn format_value(out: &mut Output, state: &State, value: &Value) -> Result<(), minijinja::Error> {
    189     // 이미 safe로 표시된 값과 HTML이 아닌 이스케이프 모드(.txt, .json)는
    190     // 기본 동작에 맡긴다. 문자열이 아닌 값도 마찬가지 — 이스케이프할 게 없다.
    191     if state.auto_escape() == AutoEscape::Html
    192         && !value.is_safe()
    193         && let Some(s) = value.as_str()
    194     {
    195         return write_html_escaped(out, s);
    196     }
    197     minijinja::escape_formatter(out, state, value)
    198 }
    199 
    200 #[derive(Debug, Clone, Serialize)]
    201 pub struct SiteCtx {
    202     pub title: String,
    203     pub description: String,
    204     /// 스킴과 호스트만. 절대 URL은 `site.origin ~ page.url`로 만든다 — 페이지 URL이
    205     /// 이미 서브경로를 품고 있어서, 설정한 `base_url`을 그대로 붙이면 두 번 들어간다.
    206     pub origin: String,
    207     /// 사이트가 도메인 루트가 아닐 때의 경로 접두사. 루트면 빈 문자열.
    208     /// 템플릿이 직접 쓰는 URL(홈 링크 등)에만 필요하다.
    209     pub base_path: String,
    210     /// 지금 렌더 중인 페이지의 언어.
    211     pub language: String,
    212     /// 이 언어의 최상위 섹션들, 언어당 한 번만 직렬화해 둔 minijinja 값.
    213     /// 사이드바는 여기서 만든다.
    214     ///
    215     /// `Vec<SectionCtx>`가 아닌 이유: 값 그대로 두면 minijinja가 **페이지마다**
    216     /// 전 섹션·전 페이지 트리를 다시 직렬화한다 — 페이지당 O(n), 빌드 전체 O(n²).
    217     /// minijinja는 `Value`를 직렬화 경계 너머로 그대로 통과시키므로, 미리 만든
    218     /// 값을 참조 카운트 복사로 나눠 주면 그 비용이 사라진다.
    219     pub sections: minijinja::Value,
    220     /// 이 언어의 Atom 피드 URL. 날짜 있는 페이지가 하나도 없으면 `None`이고,
    221     /// 그때 템플릿은 자동 발견 링크를 넣지 않으면 된다.
    222     pub feed: Option<String>,
    223     /// 이 언어의 검색 색인 URL. `[search] enabled = false`면 `None`이고,
    224     /// 그때 템플릿은 검색 UI를 넣지 않으면 된다 — `feed`와 같은 규칙이다.
    225     /// URL 조립을 템플릿에 맡기지 않는 이유이기도 하다: 색인 파일명은 엔진
    226     /// 지식이라, 템플릿이 손으로 만들면 끈 사이트에서 죽은 URL이 된다.
    227     pub search: Option<String>,
    228     /// 생성된 하이라이트 스타일시트 URL. 강조가 꺼져 있으면 `None`.
    229     pub highlight_css: Option<String>,
    230 }
    231 
    232 #[derive(Debug, Clone, Serialize)]
    233 pub struct PageCtx {
    234     pub title: String,
    235     pub description: String,
    236     pub url: String,
    237     pub permalink: String,
    238     /// 렌더된 HTML. autoescape가 켜져 있으므로 템플릿에서 `| safe`가 필요하다.
    239     pub content: String,
    240     pub weight: i64,
    241     pub draft: bool,
    242     /// front matter의 `toc` — 목차를 렌더할지에 대한 저자의 의사.
    243     pub toc: bool,
    244     /// 실제 목차 데이터. `toc = false`여도 채워진다(템플릿이 판단하게 둔다).
    245     pub toc_entries: Vec<crate::markdown::TocEntry>,
    246     pub language: String,
    247     /// 다른 언어판. **번역이 실제로 있을 때만** 채워진다.
    248     pub translations: Vec<crate::site::LanguageLink>,
    249     /// 섹션 페이지의 직속 자식. 일반 페이지에서는 비어 있다.
    250     pub children: Vec<crate::site::PageRefCtx>,
    251     /// 이 페이지가 속한 섹션. 최상위 페이지에는 없다 — 브레드크럼이 쓴다.
    252     pub section: Option<crate::site::PageRefCtx>,
    253     /// 같은 섹션의 이전/다음 페이지. 섹션이 정한 순서를 따른다.
    254     pub prev: Option<crate::site::PageRefCtx>,
    255     pub next: Option<crate::site::PageRefCtx>,
    256     /// 섹션 인덱스인지. 템플릿이 목록을 그릴지 판단한다.
    257     pub is_section: bool,
    258     /// front matter의 `[extra]`. sqzass는 여기를 읽지 않는다 — 템플릿이 읽는다.
    259     pub extra: toml::Table,
    260     /// 발행 날짜. 없으면 없다. 서식 필터 대신 조각을 준다 —
    261     /// `{{ page.date.year }}년 {{ page.date.month }}월` 처럼 템플릿이 조립한다.
    262     pub date: Option<crate::feed::PageDate>,
    263 }
    264 
    265 #[cfg(test)]
    266 mod tests {
    267     use super::*;
    268     use minijinja::context;
    269 
    270     /// 테스트가 병렬로 돌아도 겹치지 않도록 이름으로 구분한 임시 사이트 루트를 만든다.
    271     /// 반환값을 드롭할 때 디렉터리를 지운다.
    272     struct Site(std::path::PathBuf);
    273 
    274     impl Site {
    275         fn new(name: &str, files: &[(&str, &str)]) -> Self {
    276             let root =
    277                 std::env::temp_dir().join(format!("sqzass-test-{}-{name}", std::process::id()));
    278             let tdir = root.join(TEMPLATE_DIR);
    279             std::fs::create_dir_all(&tdir).unwrap();
    280             for (file, body) in files {
    281                 let path = tdir.join(file);
    282                 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    283                 std::fs::write(path, body).unwrap();
    284             }
    285             Self(root)
    286         }
    287 
    288         fn templates(&self) -> Templates {
    289             Templates::load(&self.0).unwrap()
    290         }
    291     }
    292 
    293     impl Drop for Site {
    294         fn drop(&mut self) {
    295             std::fs::remove_dir_all(&self.0).ok();
    296         }
    297     }
    298 
    299     #[test]
    300     fn resolves_extends_through_the_snapshot() {
    301         let site = Site::new(
    302             "extends",
    303             &[
    304                 ("base.html", "<main>{% block body %}{% endblock %}</main>"),
    305                 (
    306                     "page.html",
    307                     "{% extends \"base.html\" %}{% block body %}{{ page.title }}{% endblock %}",
    308                 ),
    309             ],
    310         );
    311         let out = site
    312             .templates()
    313             .render("page.html", context! { page => context!{ title => "Hi" } })
    314             .unwrap();
    315         assert_eq!(out, "<main>Hi</main>");
    316     }
    317 
    318     #[test]
    319     fn resolves_include_from_a_subdirectory() {
    320         let site = Site::new(
    321             "include",
    322             &[
    323                 ("partials/nav.html", "<nav>{{ site.title }}</nav>"),
    324                 ("page.html", "{% include \"partials/nav.html\" %}"),
    325             ],
    326         );
    327         let out = site
    328             .templates()
    329             .render(
    330                 "page.html",
    331                 context! { site => context!{ title => "sqzass" } },
    332             )
    333             .unwrap();
    334         assert_eq!(out, "<nav>sqzass</nav>");
    335     }
    336 
    337     #[test]
    338     fn autoescapes_html_by_default() {
    339         let site = Site::new("escape", &[("page.html", "{{ page.title }}")]);
    340         let out = site
    341             .templates()
    342             .render(
    343                 "page.html",
    344                 context! { page => context!{ title => "<script>" } },
    345             )
    346             .unwrap();
    347         assert!(!out.contains("<script>"), "이스케이프되지 않았다: {out}");
    348     }
    349 
    350     #[test]
    351     fn urls_keep_their_slashes() {
    352         // minijinja 기본 이스케이퍼는 `/`를 `&#x2f;`로 바꿔서 사이트의 모든 링크를
    353         // 읽을 수 없게 만든다. 커스텀 포매터로 되돌린 것을 고정한다.
    354         let site = Site::new(
    355             "slash",
    356             &[("page.html", r#"<a href="{{ page.url }}">x</a>"#)],
    357         );
    358         let out = site
    359             .templates()
    360             .render(
    361                 "page.html",
    362                 context! { page => context!{ url => "https://sqzass.sqzer.com/ko/start/" } },
    363             )
    364             .unwrap();
    365         assert_eq!(out, r#"<a href="https://sqzass.sqzer.com/ko/start/">x</a>"#);
    366     }
    367 
    368     #[test]
    369     fn dangerous_characters_are_still_escaped() {
    370         // `/`만 빼고 나머지는 그대로 이스케이프되어야 한다. 이게 깨지면 XSS다.
    371         let site = Site::new("danger", &[("page.html", "{{ page.t }}")]);
    372         let t = site.templates();
    373         for (input, expected) in [
    374             ("<script>", "&lt;script&gt;"),
    375             ("a & b", "a &amp; b"),
    376             ("say \"hi\"", "say &quot;hi&quot;"),
    377             ("it's", "it&#x27;s"),
    378             (
    379                 "</script><img onerror=x>",
    380                 "&lt;/script&gt;&lt;img onerror=x&gt;",
    381             ),
    382         ] {
    383             let out = t
    384                 .render("page.html", context! { page => context!{ t => input } })
    385                 .unwrap();
    386             assert_eq!(out, expected, "입력: {input}");
    387         }
    388     }
    389 
    390     #[test]
    391     fn safe_values_bypass_the_custom_formatter() {
    392         let site = Site::new("safebypass", &[("page.html", "{{ page.c | safe }}")]);
    393         let out = site
    394             .templates()
    395             .render(
    396                 "page.html",
    397                 context! { page => context!{ c => "<p>a &amp; b</p>" } },
    398             )
    399             .unwrap();
    400         assert_eq!(out, "<p>a &amp; b</p>");
    401     }
    402 
    403     #[test]
    404     fn plain_text_templates_are_not_html_escaped() {
    405         // robots.txt / llms.txt 같은 평문 출력은 이스케이프하면 안 된다.
    406         // `&`가 `&amp;`가 되면 파일이 망가진다.
    407         let site = Site::new("txt", &[("robots.txt", "{{ page.title }}")]);
    408         let out = site
    409             .templates()
    410             .render(
    411                 "robots.txt",
    412                 context! { page => context!{ title => "a & b" } },
    413             )
    414             .unwrap();
    415         assert_eq!(out, "a & b");
    416     }
    417 
    418     #[test]
    419     fn extensionless_templates_are_never_loaded() {
    420         // autoescape가 확장자 기반이라 확장자 없는 템플릿은 이스케이프가 꺼진다.
    421         // 로더가 애초에 안 받으므로 그 상황이 생길 수 없다는 걸 고정해둔다.
    422         let site = Site::new(
    423             "noext",
    424             &[("page.html", "ok"), ("bare", "{{ page.title }}")],
    425         );
    426         let t = site.templates();
    427         assert!(t.has("page.html"));
    428         assert!(!t.has("bare"), "확장자 없는 파일이 템플릿으로 로드됐다");
    429     }
    430 
    431     #[test]
    432     fn safe_filter_lets_rendered_html_through() {
    433         let site = Site::new("safe", &[("page.html", "{{ page.content | safe }}")]);
    434         let out = site
    435             .templates()
    436             .render(
    437                 "page.html",
    438                 context! { page => context!{ content => "<p>hi</p>" } },
    439             )
    440             .unwrap();
    441         assert_eq!(out, "<p>hi</p>");
    442     }
    443 
    444     #[test]
    445     fn undefined_attribute_access_is_an_error() {
    446         let site = Site::new("undef", &[("page.html", "{{ page.nope.deeper }}")]);
    447         let err = site
    448             .templates()
    449             .render("page.html", context! { page => context!{} });
    450         assert!(err.is_err(), "undefined 접근이 통과했다");
    451     }
    452 
    453     #[test]
    454     fn optional_field_existence_check_still_works() {
    455         // SemiStrict를 고른 이유. Strict였다면 이게 에러가 된다.
    456         let site = Site::new(
    457             "optional",
    458             &[(
    459                 "page.html",
    460                 "{% if page.missing %}yes{% else %}no{% endif %}",
    461             )],
    462         );
    463         let out = site
    464             .templates()
    465             .render("page.html", context! { page => context!{} })
    466             .unwrap();
    467         assert_eq!(out, "no");
    468     }
    469 }