src/lib.rs (32665B)
1 //! sqzass — Rust로 만든 정적 사이트 생성기.
2
3 // 엔진 피처 없이 빌드하면 two-face가 자기 피처 이름으로 에러를 내는데,
4 // 사용자가 알아야 할 이름은 우리 것이다.
5 #[cfg(not(any(feature = "onig", feature = "pure-rust")))]
6 compile_error!(
7 "정규식 엔진 피처가 필요합니다: 기본(onig) 또는 --no-default-features --features pure-rust"
8 );
9
10 pub mod assets;
11 pub mod config;
12 pub mod content;
13 pub mod doctor;
14 pub mod emit;
15 pub mod error;
16 pub mod feed;
17 pub mod highlight;
18 pub mod i18n;
19 pub mod init;
20 pub mod links;
21 pub mod markdown;
22 pub mod render;
23 pub mod search;
24 pub mod seo;
25 pub mod serve;
26 pub mod site;
27
28 use anyhow::{Context, Result};
29 use minijinja::context;
30 use std::collections::BTreeMap;
31 use std::path::{Path, PathBuf};
32
33 use error::Kind;
34
35 use config::Config;
36 use content::Page;
37 use render::{PageCtx, SiteCtx, Templates};
38 use site::Site;
39
40 #[derive(Debug, Clone)]
41 pub struct BuildOptions {
42 /// 사이트 루트 (`sqzass.toml`이 있는 디렉터리).
43 pub input: PathBuf,
44 /// 출력 디렉터리. `None`이면 `<input>/<build.output_dir>`.
45 pub output: Option<PathBuf>,
46 pub drafts: bool,
47 /// 설정의 `base_url`을 덮어쓴다.
48 pub base_url: Option<String>,
49 /// 페이즈별 소요 시간을 stderr로 낸다 (`build --profile`).
50 pub profile: bool,
51 }
52
53 /// `--profile`의 페이즈 시계. stderr로만 말한다 — stdout의 "N pages → …" 계약과
54 /// 빌드 산출물은 건드리지 않으므로 결정성과 무관하다.
55 struct Profiler {
56 enabled: bool,
57 last: std::time::Instant,
58 }
59
60 impl Profiler {
61 fn new(enabled: bool) -> Self {
62 Self {
63 enabled,
64 last: std::time::Instant::now(),
65 }
66 }
67
68 /// 직전 구간의 경과를 찍고 시계를 되돌린다.
69 fn lap(&mut self, name: &str) {
70 if self.enabled {
71 eprintln!("{name:>9} {:.1?}", self.last.elapsed());
72 }
73 self.last = std::time::Instant::now();
74 }
75 }
76
77 /// 생성된 하이라이트 스타일시트의 논리 경로. 다른 에셋과 같은 규칙으로 해시가 붙는다.
78 const HIGHLIGHT_ASSET: &str = "assets/highlight.css";
79
80 #[derive(Debug, Default)]
81 pub struct BuildStats {
82 pub pages_written: usize,
83 pub output_dir: PathBuf,
84 }
85
86 /// 빌드 산출물을 메모리에 담는다.
87 ///
88 /// 개발 서버가 디스크를 거치지 않고 이걸 그대로 서빙한다. 그래서 브라우저가 반쯤
89 /// 쓰인 파일을 읽는 일이 없고, 저장할 때마다 디스크에 쓰기가 증폭되지도 않는다.
90 #[derive(Debug, Default)]
91 pub struct BuildOutput {
92 /// 출력 디렉터리 기준 상대 경로 → 내용
93 pub files: BTreeMap<String, Vec<u8>>,
94 /// 실제 콘텐츠 페이지 수.
95 ///
96 /// `.html`을 세지 않는다. 리다이렉트 스텁과 404도 `.html`이지만 사람이 쓴
97 /// 페이지가 아니고, 그걸 세면 "5 pages"라고 보고하면서 콘텐츠는 두 개인
98 /// 상태가 된다.
99 pub pages: usize,
100 }
101
102 /// 사이트를 빌드해 메모리에 담는다. 디스크는 건드리지 않는다.
103 pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
104 render_site(opts, load_config(opts)?)
105 }
106
107 /// 설정을 읽고 명령줄 덮어쓰기를 적용한다.
108 ///
109 /// 빌드 경로가 이걸 두 번 부르지 않도록 따로 뺐다. 두 번 부르면 설정 진단이 두 번
110 /// 찍히고, 사용자는 자기 파일에 문제가 두 개 있다고 읽는다.
111 fn load_config(opts: &BuildOptions) -> Result<Config> {
112 let mut cfg = Config::load(&opts.input).map_err(Kind::Config.tag())?;
113 if let Some(base) = &opts.base_url {
114 cfg.base_url = base.clone();
115 }
116 Ok(cfg)
117 }
118
119 fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
120 let root = &opts.input;
121 let drafts = opts.drafts || cfg.build.drafts;
122 let mut prof = Profiler::new(opts.profile);
123
124 let pages = content::discover(root, &cfg, drafts).map_err(Kind::Content.tag())?;
125 let site = Site::build(&pages, &cfg);
126 prof.lap("discover");
127
128 // 에셋을 먼저 처리해야 템플릿이 해시가 붙은 최종 URL을 조회할 수 있다.
129 let mut assets =
130 assets::Assets::collect(root, &cfg.assets, cfg.base_path()).map_err(Kind::Io.tag())?;
131
132 let mut md =
133 markdown::Renderer::new(&cfg.markdown).with_links(site.link_index(), &cfg.default_language);
134 let highlight_css_url = if cfg.highlight.enabled {
135 md = md.with_highlighter(highlight::Highlighter::new());
136 // 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
137 // 이 파일 하나를 바꾸면 사이트 전체의 코드 색이 바뀐다.
138 let css = highlight::stylesheet(&cfg.highlight).map_err(Kind::Config.tag())?;
139 assets.insert(HIGHLIGHT_ASSET, css.into_bytes(), cfg.assets.fingerprint);
140 assets.url(HIGHLIGHT_ASSET).map(str::to_string)
141 } else {
142 None
143 };
144 prof.lap("assets");
145
146 // 피드를 먼저 만든다. 페이지가 <head>에 자동 발견 링크를 넣으려면 자기 언어에
147 // 피드가 있는지를 렌더 시점에 알아야 한다.
148 let mut feeds: BTreeMap<String, String> = BTreeMap::new();
149 {
150 let mut by_lang: BTreeMap<&str, Vec<&Page>> = BTreeMap::new();
151 for p in &pages {
152 by_lang.entry(p.language.as_str()).or_default().push(p);
153 }
154 for (lang, langs_pages) in by_lang {
155 let home = if lang == cfg.default_language {
156 format!("{}/", cfg.base_path())
157 } else {
158 format!("{}/{lang}/", cfg.base_path())
159 };
160 let self_url = format!("{}/{}", cfg.base_path(), feed::path(lang));
161 if let Some(xml) = feed::atom(
162 &langs_pages,
163 lang,
164 &cfg.title,
165 cfg.origin(),
166 &home,
167 &self_url,
168 ) {
169 feeds.insert(lang.to_string(), xml);
170 }
171 }
172 }
173 prof.lap("feeds");
174
175 // 사이트가 실제로 내보내는 URL 전체. 이게 있어야 `[설치](/start/install/)` 같은
176 // 평범한 절대 경로 링크도 빌드가 검증할 수 있다 — 지금까지는 `@/`만 봤다.
177 let base = cfg.base_path();
178 let generated = [
179 seo::SITEMAP_PATH,
180 seo::ROBOTS_PATH,
181 emit::LLMS_PATH,
182 emit::NOT_FOUND_PATH,
183 assets::MANIFEST_PATH,
184 ];
185 let known = links::KnownUrls {
186 urls: pages
187 .iter()
188 .map(|p| p.url.clone())
189 .chain(assets.manifest.values().cloned())
190 // 빌드가 만드는 파일도 존재하는 URL이다. 빼 두면 우리 사이트의
191 // 피드나 sitemap을 가리키는 링크가 "어디도 가리키지 않는다"며
192 // 빌드를 깨뜨린다 — 실제로 그랬다.
193 .chain(generated.iter().map(|p| format!("{base}/{p}")))
194 .chain(
195 feeds
196 .keys()
197 .map(|lang| format!("{base}/{}", feed::path(lang))),
198 )
199 // 설정의 [languages]가 아니라 **실제 페이지의 언어**로 만든다.
200 // 언어를 선언하지 않은 사이트도 기본 언어로 색인을 내보내므로,
201 // 설정을 기준으로 삼으면 그 사이트에서 색인 링크가 죽는다.
202 // 색인을 끈 사이트에서는 넣지 않는다 — 존재하지 않는 파일을 알려진
203 // URL로 등록하면 죽은 링크를 살아 있다고 판정하게 된다.
204 .chain(if cfg.search.enabled {
205 pages
206 .iter()
207 .map(|p| p.language.as_str())
208 .collect::<std::collections::BTreeSet<_>>()
209 .into_iter()
210 .map(|lang| format!("{base}/{}", search::path(lang)))
211 .collect::<Vec<_>>()
212 } else {
213 Vec::new()
214 })
215 .collect(),
216 };
217 md = md.with_known_urls(known);
218
219 assets.write_manifest();
220 let templates = Templates::load(root)
221 .map_err(Kind::Template.tag())?
222 .with_assets(assets.manifest.clone())
223 .with_i18n(i18n::load(root).map_err(Kind::Template.tag())?);
224
225 // 사이드바 컨텍스트는 언어당 한 번만 만들어 미리 minijinja 값으로 직렬화한다.
226 // 페이지마다 전 섹션·전 페이지를 재조립해 다시 직렬화하면 페이지당 O(n),
227 // 빌드 전체 O(n²)다 — 5000페이지 실측에서 링크 표 복사와 함께 지배 항이었다.
228 // `Value` 복사는 참조 카운트라 페이지마다 나눠 주는 비용은 포인터 복사다.
229 let sections_by_lang: BTreeMap<&str, minijinja::Value> = pages
230 .iter()
231 .map(|p| p.language.as_str())
232 .collect::<std::collections::BTreeSet<_>>()
233 .into_iter()
234 .map(|lang| {
235 let ctx = site::section_ctx(site.sections(lang), site.pages);
236 (lang, minijinja::Value::from_serialize(&ctx))
237 })
238 .collect();
239 prof.lap("templates");
240
241 let mut out = BuildOutput {
242 pages: pages.len(),
243 ..BuildOutput::default()
244 };
245 out.files.extend(assets.files);
246
247 // 언어 코드 → 그 언어의 검색 색인 행들.
248 let mut index: BTreeMap<String, Vec<search::Entry>> = BTreeMap::new();
249
250 // 페이지 렌더는 서로 완전히 독립이라 병렬로 돌린다. 결정성은 스케줄이 아니라
251 // 병합이 정한다: 각 결과를 페이지 인덱스 자리에 꽂고 입력 순서대로 합치므로,
252 // 출력 바이트도 — 실패할 때 어느 에러가 보이는지도 — 스레드 수와 무관하다.
253 // 분배는 원자 카운터 훔치기다. 코드 블록이 많은 페이지와 없는 페이지의 렌더
254 // 비용 차가 커서, 미리 나누는 청크 분배는 가장 느린 청크를 기다리게 된다.
255 let render_one = |page: &Page| {
256 render_page(
257 page,
258 &site,
259 &cfg,
260 &templates,
261 &md,
262 sections_by_lang
263 .get(page.language.as_str())
264 .cloned()
265 .expect("언어별 섹션 컨텍스트는 모든 페이지 언어에 대해 만들었다"),
266 highlight_css_url.as_deref(),
267 feeds
268 .contains_key(&page.language)
269 .then(|| format!("{}/{}", cfg.base_path(), feed::path(&page.language)))
270 .as_deref(),
271 )
272 };
273 let workers = std::thread::available_parallelism()
274 .map(|n| n.get())
275 .unwrap_or(1)
276 .min(pages.len())
277 .max(1);
278 let mut results: Vec<Option<Result<RenderedPage>>> =
279 std::iter::repeat_with(|| None).take(pages.len()).collect();
280 let next = std::sync::atomic::AtomicUsize::new(0);
281 let done = std::thread::scope(|scope| {
282 let handles: Vec<_> = (0..workers)
283 .map(|_| {
284 scope.spawn(|| {
285 let mut local = Vec::new();
286 loop {
287 let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
288 let Some(page) = pages.get(i) else { break };
289 local.push((i, render_one(page)));
290 }
291 local
292 })
293 })
294 .collect();
295 handles
296 .into_iter()
297 .flat_map(|h| h.join().expect("렌더 스레드가 패닉했다"))
298 .collect::<Vec<_>>()
299 });
300 for (i, r) in done {
301 results[i] = Some(r);
302 }
303 for (page, result) in pages.iter().zip(results) {
304 let (html, entry) = result.expect("모든 페이지가 렌더된다")?;
305 out.files.insert(
306 page.out_path.to_string_lossy().replace('\\', "/"),
307 html.into_bytes(),
308 );
309 if let Some(entry) = entry {
310 index.entry(page.language.clone()).or_default().push(entry);
311 }
312 }
313 prof.lap("render");
314
315 for (language, entries) in index {
316 let json = serde_json::to_vec(&entries)
317 .with_context(|| format!("{language} 검색 색인을 만들 수 없습니다"))?;
318 // `static/`에 같은 이름이 있으면 그쪽이 이긴다. 피드·sitemap·robots과
319 // 같은 규칙이다 — 직접 넣은 색인이 조용히 덮이는 건 어느 쪽이든 나쁘다.
320 out.files.entry(search::path(&language)).or_insert(json);
321 }
322 prof.lap("search");
323
324 // 옛 URL을 새 URL로 보내는 스텁. GitHub Pages에는 리다이렉트 규칙이 없으므로
325 // 이걸 빌드가 만들지 않으면 아무도 만들어 주지 않는다.
326 for page in &pages {
327 for alias in &page.front.aliases {
328 let out_path = emit::alias_output_path(alias, page).map_err(Kind::Content.tag())?;
329 let stub = emit::redirect_stub(&page.url, &format!("{}{}", cfg.origin(), page.url));
330 if let Some(prev) = out.files.insert(out_path.clone(), stub.into_bytes()) {
331 let _ = prev;
332 return Err(Kind::Content.tag()(anyhow::anyhow!(
333 "{}: alias `{alias}` 가 이미 존재하는 출력 `{out_path}` 를 덮어씁니다.\n\
334 다른 페이지나 다른 alias가 같은 URL을 주장하고 있습니다.",
335 page.source.display()
336 )));
337 }
338 }
339 }
340
341 // 호스트들이 이름으로 찾는 404. 템플릿이 있을 때만 만든다 — 하이라이트
342 // 스타일시트와 같은 계약이다.
343 if templates.has(emit::NOT_FOUND_PATH) {
344 let html = render_standalone(
345 emit::NOT_FOUND_PATH,
346 &cfg,
347 &templates,
348 // 기본 언어에 페이지가 하나도 없으면 섹션도 없다 — 빈 목록이 옳다.
349 sections_by_lang
350 .get(cfg.default_language.as_str())
351 .cloned()
352 .unwrap_or_else(
353 || minijinja::Value::from_serialize(Vec::<site::SectionCtx>::new()),
354 ),
355 highlight_css_url.as_deref(),
356 )
357 .map_err(Kind::Template.tag())?;
358 out.files
359 .insert(emit::NOT_FOUND_PATH.into(), html.into_bytes());
360 }
361
362 for (lang, xml) in feeds {
363 // `static/`에 같은 이름이 있으면 그쪽이 이긴다. sitemap·robots과 같은
364 // 규칙이다 — 직접 넣은 파일이 조용히 덮이는 건 어느 쪽이든 나쁘다.
365 out.files
366 .entry(feed::path(&lang))
367 .or_insert_with(|| xml.into_bytes());
368 }
369
370 out.files.entry(emit::LLMS_PATH.into()).or_insert_with(|| {
371 emit::llms_txt(&cfg.title, &cfg.description, cfg.origin(), &pages).into_bytes()
372 });
373
374 // `static/`에 같은 이름이 있으면 그쪽이 이긴다. 직접 넣은 robots.txt가
375 // 조용히 무시당하는 것보다는 생성을 건너뛰는 쪽이 낫다.
376 let origin = cfg.origin();
377 out.files
378 .entry(seo::SITEMAP_PATH.into())
379 .or_insert_with(|| seo::sitemap(&pages, &site, origin).into_bytes());
380 out.files
381 .entry(seo::ROBOTS_PATH.into())
382 .or_insert_with(|| seo::robots(origin, cfg.base_path()).into_bytes());
383
384 // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
385 // `_`로 시작하는 디렉터리를 통째로 삼킨다.
386 out.files.insert(".nojekyll".into(), Vec::new());
387
388 check_output_collisions(&out).map_err(Kind::Content.tag())?;
389 prof.lap("generate");
390
391 Ok(out)
392 }
393
394 /// 사이트를 읽고 지적할 것들을 모은다. 아무것도 쓰지 않는다.
395 pub fn diagnose(opts: &BuildOptions) -> Result<Vec<doctor::Finding>> {
396 let cfg = load_config(opts)?;
397 let pages = content::discover(&opts.input, &cfg, opts.drafts || cfg.build.drafts)
398 .map_err(Kind::Content.tag())?;
399 let site = Site::build(&pages, &cfg);
400 doctor::run(&opts.input, &cfg, &pages, &site).map_err(Kind::Other.tag())
401 }
402
403 /// 사이트를 빌드해 디스크에 쓴다.
404 pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
405 let cfg = load_config(opts)?;
406 let out_dir = resolve_output_dir(opts, &cfg);
407 let output = render_site(opts, cfg)?;
408 let mut prof = Profiler::new(opts.profile);
409
410 // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
411 if out_dir.exists() {
412 std::fs::remove_dir_all(&out_dir)
413 .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))
414 .map_err(Kind::Io.tag())?;
415 }
416
417 for (rel, bytes) in &output.files {
418 let dest = out_dir.join(rel);
419 if let Some(parent) = dest.parent() {
420 std::fs::create_dir_all(parent)
421 .with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
422 }
423 std::fs::write(&dest, bytes)
424 .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))
425 .map_err(Kind::Io.tag())?;
426 }
427 prof.lap("write");
428
429 Ok(BuildStats {
430 pages_written: output.pages,
431 output_dir: out_dir,
432 })
433 }
434
435 /// `-o`가 없으면 `<input>/<output_dir>`, 있으면 **셸의 CWD 기준**으로 해석한다.
436 fn resolve_output_dir(opts: &BuildOptions, cfg: &Config) -> PathBuf {
437 match &opts.output {
438 Some(o) => o.clone(),
439 None => opts.input.join(&cfg.build.output_dir),
440 }
441 }
442
443 /// 한 경로가 파일이면서 동시에 디렉터리일 수는 없다.
444 ///
445 /// `feed-en.xml`이라는 이름의 페이지를 만들면 출력에 `feed-en.xml`(파일)과
446 /// `feed-en.xml/index.html`(디렉터리)이 함께 생긴다. 쓰는 단계에서야
447 /// `File exists (os error 17)`로 죽는데, 그때는 이미 절반이 디스크에 나가 있고
448 /// 메시지는 원인을 하나도 말해 주지 않는다. URL 충돌과 같은 부류이므로 같은
449 /// 자리에서, 같은 방식으로 막는다.
450 fn check_output_collisions(out: &BuildOutput) -> Result<()> {
451 for key in out.files.keys() {
452 let prefix = format!("{key}/");
453 // BTreeMap이 정렬돼 있으므로 접두사를 공유하는 이웃만 보면 된다.
454 if let Some(other) = out.files.range(prefix.clone()..).next()
455 && other.0.starts_with(&prefix)
456 {
457 anyhow::bail!(
458 "출력 경로 충돌: `{key}` 가 파일이면서 동시에 `{}` 의 디렉터리입니다.\n\
459 빌드가 만드는 파일과 같은 이름의 페이지가 있는지 확인하세요 \
460 (sitemap.xml, robots.txt, llms.txt, 404.html, feed-<언어>.xml, search-<언어>.json).",
461 other.0
462 );
463 }
464 }
465 Ok(())
466 }
467
468 /// 페이지 HTML과 그 페이지의 검색 색인 행을 함께 낸다. 본문 평문은 렌더 과정에서
469 /// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다. 색인을 끈 사이트에는
470 /// 행을 만들지 않는다 — 페이지당 본문 사본 하나가 통째로 사라진다.
471 /// 페이지 하나의 렌더 결과: HTML과, 검색이 켜져 있을 때의 색인 행.
472 type RenderedPage = (String, Option<search::Entry>);
473
474 // 렌더 루프 한 곳에서만 부르는 내부 함수라 인자를 묶는 구조체가 이름값을 못 한다.
475 #[allow(clippy::too_many_arguments)]
476 fn render_page(
477 page: &Page,
478 site: &Site,
479 cfg: &Config,
480 templates: &Templates,
481 md: &markdown::Renderer,
482 sections: minijinja::Value,
483 highlight_css: Option<&str>,
484 feed_url: Option<&str>,
485 ) -> Result<RenderedPage> {
486 let template = select_template(page, site, templates).map_err(Kind::Template.tag())?;
487
488 let site_ctx = SiteCtx {
489 title: cfg.title.clone(),
490 description: cfg.description.clone(),
491 origin: cfg.origin().to_string(),
492 base_path: cfg.base_path().to_string(),
493 language: page.language.clone(),
494 sections,
495 feed: feed_url.map(str::to_string),
496 search: cfg
497 .search
498 .enabled
499 .then(|| format!("{}/{}", cfg.base_path(), search::path(&page.language))),
500 highlight_css: highlight_css.map(str::to_string),
501 };
502
503 let rendered = md.render_in(&page.language, &page.body);
504 if !rendered.unresolved.is_empty() {
505 return Err(Kind::Content.tag()(anyhow::anyhow!(
506 "{}: 어디도 가리키지 않는 링크가 있습니다:\n {}\n\n\
507 `@/`로 시작하는 링크는 content/ 기준 소스 경로여야 합니다 \
508 (예: `@/start/installation.md`).\n\
509 `/`로 시작하는 링크는 이 사이트가 실제로 내보내는 URL이어야 합니다.\n\
510 CSS와 JS는 파일명에 해시가 붙으므로 마크다운에서 `/css/main.css`로 \
511 가리킬 수 없습니다 — 템플릿에서 `asset(\"css/main.css\")`를 쓰세요.",
512 page.source.display(),
513 rendered.unresolved.join("\n ")
514 )));
515 }
516 if !rendered.bad_fences.is_empty() {
517 return Err(Kind::Content.tag()(anyhow::anyhow!(
518 "{}: 잘못된 코드 펜스 옵션이 있습니다:\n {}",
519 page.source.display(),
520 rendered.bad_fences.join("\n ")
521 )));
522 }
523
524 // 섹션 인덱스는 자기 자신이 섹션이라 라벨과 제목이 겹친다. 그때는 비워 둔다.
525 let section = if page.is_section {
526 None
527 } else {
528 site.section_ref_of(page)
529 };
530
531 let (prev, next) = site.neighbours_of(page);
532
533 let entry = cfg.search.enabled.then(|| search::Entry {
534 t: page.title.clone(),
535 d: page.front.description.clone(),
536 u: page.url.clone(),
537 s: section
538 .as_ref()
539 .map(|s| s.title.clone())
540 .unwrap_or_default(),
541 c: rendered.text.clone(),
542 });
543
544 let page_ctx = PageCtx {
545 title: page.title.clone(),
546 description: page.front.description.clone(),
547 url: page.url.clone(),
548 // page.url이 서브경로를 품고 있으므로 origin만 붙인다.
549 permalink: format!("{}{}", cfg.origin(), page.url),
550 content: rendered.html,
551 weight: page.front.weight,
552 draft: page.front.draft,
553 // `toc`는 "목차를 보여줄지"라는 저자의 의사, `toc_entries`는 실제 데이터다.
554 toc: page.front.toc,
555 toc_entries: rendered.toc,
556 language: page.language.clone(),
557 // 번역이 실제로 있는 언어만 담긴다 — 템플릿은 이게 비었는지로
558 // 언어 전환 UI를 보일지 결정하면 된다.
559 translations: site.translations_of(page),
560 children: site.children_of(page),
561 section,
562 prev,
563 next,
564 is_section: page.is_section,
565 extra: page.front.extra.clone(),
566 date: page.front.date.as_ref().and_then(feed::PageDate::from_toml),
567 };
568
569 let html = templates
570 .render(&template, context! { site => site_ctx, page => page_ctx })
571 .with_context(|| format!("{} 렌더 중", page.source.display()))
572 .map_err(Kind::Template.tag())?;
573
574 Ok((html, entry))
575 }
576
577 /// 페이지에 매이지 않은 템플릿 하나를 렌더한다. 지금은 404뿐이다.
578 ///
579 /// 일부러 이름이 붙은 단일 분기로 둔다. "아무 템플릿이나 아무 경로로 렌더한다"는
580 /// 일반 기구를 만들면 그 순간 출력 URL 소유권을 누가 갖는지가 흐려진다.
581 fn render_standalone(
582 name: &str,
583 cfg: &Config,
584 templates: &Templates,
585 sections: minijinja::Value,
586 highlight_css: Option<&str>,
587 ) -> Result<String> {
588 let language = cfg.default_language.clone();
589 let url = format!("{}/{}", cfg.base_path(), name);
590
591 let site_ctx = SiteCtx {
592 title: cfg.title.clone(),
593 description: cfg.description.clone(),
594 origin: cfg.origin().to_string(),
595 base_path: cfg.base_path().to_string(),
596 language: language.clone(),
597 sections,
598 feed: None,
599 // 404에도 검색은 준다 — 길 잃은 독자가 빠져나가는 길이다.
600 search: cfg
601 .search
602 .enabled
603 .then(|| format!("{}/{}", cfg.base_path(), search::path(&language))),
604 highlight_css: highlight_css.map(str::to_string),
605 };
606
607 let page_ctx = PageCtx {
608 title: "404".into(),
609 description: String::new(),
610 permalink: format!("{}{url}", cfg.origin()),
611 url,
612 content: String::new(),
613 weight: 0,
614 draft: false,
615 toc: false,
616 toc_entries: Vec::new(),
617 language,
618 translations: Vec::new(),
619 children: Vec::new(),
620 section: None,
621 prev: None,
622 next: None,
623 is_section: false,
624 extra: toml::Table::new(),
625 date: None,
626 };
627
628 templates
629 .render(name, context! { site => site_ctx, page => page_ctx })
630 .with_context(|| format!("{name} 렌더 중"))
631 }
632
633 /// 템플릿 선택은 **명시적**이다. 순서는 딱 넷:
634 ///
635 /// 1. front matter `template`
636 /// 2. 섹션이면 `section.html`
637 /// 3. 부모 섹션의 `page_template`
638 /// 4. `page.html`
639 ///
640 /// Hugo의 20단계 lookup order 캐스케이드는 채택하지 않는다 — kind × section × type ×
641 /// layout × language × output format을 곱해 만든 순서표는 사용자가 가장 많이 헤매는
642 /// 지점이고, "해석 순서를 출력이라도 해달라"는 요청이 10년째 열려 있다.
643 pub(crate) fn select_template(page: &Page, site: &Site, templates: &Templates) -> Result<String> {
644 if let Some(explicit) = &page.front.template {
645 if templates.has(explicit) {
646 return Ok(explicit.clone());
647 }
648 anyhow::bail!(
649 "{}: front matter가 template = \"{}\"를 가리키는데 그런 템플릿이 없습니다.\n\
650 사용 가능한 템플릿: {}",
651 page.source.display(),
652 explicit,
653 templates.names().join(", ")
654 );
655 }
656
657 if page.is_section && templates.has("section.html") {
658 return Ok("section.html".into());
659 }
660
661 // 없는 이름은 front matter `template`과 똑같이 에러다. 조용히 page.html로
662 // 떨어뜨리면 섹션 하나가 통째로 잘못된 템플릿으로 렌더되고, 그걸 알아채는
663 // 자리는 빌드가 아니라 브라우저다.
664 if !page.is_section
665 && let Some(pt) = site.page_template_for(page)
666 {
667 if templates.has(pt) {
668 return Ok(pt.to_string());
669 }
670 anyhow::bail!(
671 "{}: 섹션이 page_template = \"{}\"를 가리키는데 그런 템플릿이 없습니다.\n\
672 사용 가능한 템플릿: {}",
673 page.source.display(),
674 pt,
675 templates.names().join(", ")
676 );
677 }
678
679 let candidate = if page.is_section {
680 "section.html"
681 } else {
682 "page.html"
683 };
684 if templates.has(candidate) {
685 return Ok(candidate.into());
686 }
687 if templates.has("page.html") {
688 return Ok("page.html".into());
689 }
690
691 anyhow::bail!(
692 "{}: 쓸 템플릿이 없습니다 ('{}'도 'page.html'도 없음).\n\
693 사용 가능한 템플릿: {}",
694 page.source.display(),
695 candidate,
696 templates.names().join(", ")
697 )
698 }
699
700 /// 경로를 사람이 읽기 좋게 줄인다 (로그용).
701 pub fn display_path(path: &Path) -> String {
702 std::env::current_dir()
703 .ok()
704 .and_then(|cwd| path.strip_prefix(&cwd).ok().map(|p| p.to_path_buf()))
705 .unwrap_or_else(|| path.to_path_buf())
706 .display()
707 .to_string()
708 }
709
710 #[cfg(test)]
711 mod search_toggle_tests {
712 use super::*;
713
714 /// init 뼈대에 설정을 덧붙인 임시 사이트. 반환된 경로는 테스트가 지운다.
715 fn site_with(extra_config: &str, name: &str) -> PathBuf {
716 let dir = std::env::temp_dir().join(format!("sqzass-search-{}-{name}", std::process::id()));
717 let _ = std::fs::remove_dir_all(&dir);
718 init::init(&dir).unwrap();
719 let cfg_path = dir.join(config::CONFIG_FILE);
720 let mut cfg = std::fs::read_to_string(&cfg_path).unwrap();
721 cfg.push_str(extra_config);
722 std::fs::write(&cfg_path, cfg).unwrap();
723 dir
724 }
725
726 fn opts(dir: &Path) -> BuildOptions {
727 BuildOptions {
728 input: dir.to_path_buf(),
729 output: None,
730 drafts: false,
731 base_url: None,
732 profile: false,
733 }
734 }
735
736 #[test]
737 fn search_index_is_generated_by_default() {
738 let dir = site_with("", "on");
739 let out = build_to_memory(&opts(&dir)).unwrap();
740 assert!(
741 out.files.contains_key(&search::path("en")),
742 "실제 파일들: {:?}",
743 out.files.keys().collect::<Vec<_>>()
744 );
745 let _ = std::fs::remove_dir_all(&dir);
746 }
747
748 #[test]
749 fn disabled_search_emits_no_index_at_all() {
750 let dir = site_with("\n[search]\nenabled = false\n", "off");
751 let out = build_to_memory(&opts(&dir)).unwrap();
752 let leaked: Vec<_> = out
753 .files
754 .keys()
755 .filter(|k| k.starts_with("search-"))
756 .collect();
757 assert!(leaked.is_empty(), "색인이 나왔다: {leaked:?}");
758 let _ = std::fs::remove_dir_all(&dir);
759 }
760
761 #[test]
762 fn templates_see_the_index_url_or_nothing() {
763 // `site.search`는 `site.feed`와 같은 규칙이다 — 켜져 있으면 이 언어의
764 // 색인 URL, 꺼져 있으면 없음. 테마가 두 경우 모두에서 동작하는 근거다.
765 let tpl = "{% if site.search %}INDEX={{ site.search }}{% else %}NO-SEARCH{% endif %}";
766
767 let on = site_with("", "ctx-on");
768 std::fs::write(on.join("templates/page.html"), tpl).unwrap();
769 let html = build_to_memory(&opts(&on)).unwrap().files["index.html"].clone();
770 let html = String::from_utf8(html).unwrap();
771 assert!(html.contains("INDEX=/search-en.json"), "실제: {html}");
772
773 let off = site_with("\n[search]\nenabled = false\n", "ctx-off");
774 std::fs::write(off.join("templates/page.html"), tpl).unwrap();
775 let html = build_to_memory(&opts(&off)).unwrap().files["index.html"].clone();
776 let html = String::from_utf8(html).unwrap();
777 assert!(html.contains("NO-SEARCH"), "실제: {html}");
778
779 let _ = std::fs::remove_dir_all(&on);
780 let _ = std::fs::remove_dir_all(&off);
781 }
782
783 #[test]
784 fn a_static_search_index_wins_over_the_generated_one() {
785 let dir = site_with("", "static-wins");
786 std::fs::create_dir_all(dir.join("static")).unwrap();
787 std::fs::write(dir.join("static/search-en.json"), b"{\"handmade\":true}").unwrap();
788 let out = build_to_memory(&opts(&dir)).unwrap();
789 assert_eq!(
790 out.files.get(&search::path("en")).map(Vec::as_slice),
791 Some(b"{\"handmade\":true}".as_slice()),
792 "생성 색인이 static/ 파일을 덮었다"
793 );
794 let _ = std::fs::remove_dir_all(&dir);
795 }
796 }
797
798 #[cfg(test)]
799 mod template_selection_tests {
800 use super::*;
801
802 /// 없는 `page_template`은 front matter `template`과 똑같이 빌드를 멈춰야 한다.
803 /// 조용히 `page.html`로 떨어지면 섹션 하나가 통째로 잘못된 템플릿으로 나가고,
804 /// 빌드는 성공했다고 말한다.
805 #[test]
806 fn a_page_template_naming_nothing_stops_the_build() {
807 let dir = std::env::temp_dir().join(format!("sqzass-pt-{}", std::process::id()));
808 let _ = std::fs::remove_dir_all(&dir);
809 init::init(&dir).unwrap();
810 std::fs::create_dir_all(dir.join("content/blog")).unwrap();
811 std::fs::write(
812 dir.join("content/blog/_index.md"),
813 "+++\ntitle = \"Blog\"\npage_template = \"nope.html\"\n+++\n",
814 )
815 .unwrap();
816 std::fs::write(
817 dir.join("content/blog/post.md"),
818 "+++\ntitle = \"Post\"\n+++\n",
819 )
820 .unwrap();
821
822 let err = build_to_memory(&BuildOptions {
823 input: dir.clone(),
824 output: None,
825 drafts: false,
826 base_url: None,
827 profile: false,
828 })
829 .unwrap_err();
830 let msg = format!("{err:#}");
831 assert!(
832 msg.contains("nope.html"),
833 "에러가 없는 이름을 짚어야 한다: {msg}"
834 );
835 let _ = std::fs::remove_dir_all(&dir);
836 }
837 }