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 이스케이프하면 `&`가 `&`가
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는 `/`를 `/`로 바꾼다(`utils.rs`의 `b'/' => "/"`). OWASP식
161 /// belt-and-braces 조치인데, 노리는 건 `</script>` 탈출이고 그건 `<`를 이스케이프하는
162 /// 것으로 이미 막힌다. 대가는 사이트의 **모든 URL**이 `href="https://…"`로
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 '&' => "&",
174 '<' => "<",
175 '>' => ">",
176 '"' => """,
177 '\'' => "'",
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 기본 이스케이퍼는 `/`를 `/`로 바꿔서 사이트의 모든 링크를
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>", "<script>"),
375 ("a & b", "a & b"),
376 ("say \"hi\"", "say "hi""),
377 ("it's", "it's"),
378 (
379 "</script><img onerror=x>",
380 "</script><img onerror=x>",
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 & b</p>" } },
398 )
399 .unwrap();
400 assert_eq!(out, "<p>a & b</p>");
401 }
402
403 #[test]
404 fn plain_text_templates_are_not_html_escaped() {
405 // robots.txt / llms.txt 같은 평문 출력은 이스케이프하면 안 된다.
406 // `&`가 `&`가 되면 파일이 망가진다.
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 }