sqzass.git

sqzass

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

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 }