sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit 510b12d340b08740c22331de19a9d8e3df84fb27
parent a9bae04df9fddcb340455735eff71223c92e98a7
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Tue, 28 Jul 2026 15:41:36 +0900

[search] enabled 로 색인 생성을 끌 수 있게 한다

색인은 전 페이지의 본문 평문을 담아 코퍼스 크기에 비례한다 — 이 문서
사이트에서 언어당 ~80KB, 5000페이지 실측에서 16MB. 검색 UI가 없는
사이트가 그 바이트를 지불할 이유가 없다.

끈 사이트에서는 알려진 URL 목록에도 색인 경로를 넣지 않는다. 존재하지
않는 파일을 알려진 URL로 등록하면 죽은 링크를 살아 있다고 판정하게 된다.
색인 행 자체를 만들지 않으므로 페이지당 본문 사본 하나가 통째로 사라진다.

적대적 검증이 반대쪽 구멍을 찾았다: enabled=true일 때 static/에 직접 둔
search-<언어>.json을 생성 색인이 조용히 덮어썼다. 피드·sitemap·robots은
전부 static에 양보하는데 색인만 예외였다 — 같은 규칙으로 맞춘다.
static/feed-en.xml이 조용히 덮이던 것을 피드 작업 검증에서 고친 것과
같은 부류다.

Diffstat:
Mdocs/content/features/search.ko.md | 9+++++++++
Mdocs/content/features/search.md | 9+++++++++
Mdocs/content/start/configuration.ko.md | 11+++++++++++
Mdocs/content/start/configuration.md | 11+++++++++++
Msrc/config.rs | 19+++++++++++++++++++
Msrc/lib.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
6 files changed, 144 insertion(+), 9 deletion(-)

diff --git a/docs/content/features/search.ko.md b/docs/content/features/search.ko.md
@@ -49,6 +49,15 @@ JSON 파일 하나입니다.
 선택이 되는 지점이 분명히 있습니다. 다만 그 지점은 문서 사이트보다 훨씬 뒤에 있고,
 거기 도달하는 건 자기 문서의 단어를 못 찾는 검색을 배포하는 것보다 나은 문제입니다.
 
+검색 UI가 없는 사이트라면 그 몇 KB도 낼 이유가 없습니다.
+
+```toml
+[search]
+enabled = false
+```
+
+색인 생성을 통째로 건너뜁니다.
+
 ## 순위
 
 질의의 모든 단어가 그 행 어딘가에 나타나야 합니다. OR가 아니라 AND입니다. 제목에서
diff --git a/docs/content/features/search.md b/docs/content/features/search.md
@@ -52,6 +52,15 @@ with your documentation, and there is a size past which this is the wrong
 design — but that size is far beyond a documentation site, and reaching it is a
 better problem than shipping search that cannot find your own words.
 
+A site that ships no search UI should not pay even that:
+
+```toml
+[search]
+enabled = false
+```
+
+skips the index entirely.
+
 ## Ranking
 
 Every term in the query must appear somewhere in a row — an AND, not an OR.
diff --git a/docs/content/start/configuration.ko.md b/docs/content/start/configuration.ko.md
@@ -107,6 +107,17 @@ CommonMark의 flanking 규칙은 단어 사이에 공백을 두는 언어를 전
 |---|---|---|
 | `sort_by` | `"weight"` | `weight`, `title`, `date`. 섹션이 각자 덮어쓸 수 있습니다. |
 
+## `[search]`
+
+| 키 | 기본값 | |
+|---|---|---|
+| `enabled` | `true` | 언어별 `search-<lang>.json`을 만듭니다. |
+
+색인은 모든 페이지의 본문을 담아 콘텐츠와 함께 커집니다 — 검색 UI가 없는
+사이트가 그 값을 치를 이유가 없습니다. 끄면 색인이 만들어지지 않고, 링크가
+가리킬 색인 URL도 존재하지 않게 됩니다. [검색](@/features/search.md)을
+참고하세요.
+
 ## 명령줄에서 덮어쓰기
 
 `--base-url`과 `--drafts`는 파일보다 우선합니다. 설정 하나로 프리뷰 배포와
diff --git a/docs/content/start/configuration.md b/docs/content/start/configuration.md
@@ -108,6 +108,17 @@ See [Static assets](@/templates/assets.md).
 |---|---|---|
 | `sort_by` | `"weight"` | `weight`, `title` or `date`. A section can override it. |
 
+## `[search]`
+
+| Key | Default | |
+|---|---|---|
+| `enabled` | `true` | Emit `search-<lang>.json`, one per language. |
+
+The index carries the body text of every page, so it grows with your content —
+a site with no search UI has no reason to pay for it. Disabled, no index is
+written and no index URL exists for links to point at. See
+[Search](@/features/search.md).
+
 ## Overriding from the command line
 
 `--base-url` and `--drafts` win over the file, which is what makes one
diff --git a/src/config.rs b/src/config.rs
@@ -31,6 +31,8 @@ pub struct Config {
     pub assets: Assets,
     #[serde(default)]
     pub nav: Nav,
+    #[serde(default)]
+    pub search: Search,
 }
 
 #[derive(Debug, Clone, Deserialize)]
@@ -130,6 +132,23 @@ impl Default for Assets {
     }
 }
 
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Search {
+    /// 검색 색인(`search-<언어>.json`) 생성 여부.
+    ///
+    /// 색인은 전 페이지의 본문 평문을 담으므로 코퍼스 크기에 비례한다(이 문서
+    /// 사이트에서 언어당 ~80KB, 5000페이지 실측에서 16MB). 검색 UI가 없는
+    /// 사이트가 그 바이트를 강제로 지불할 이유가 없다.
+    pub enabled: bool,
+}
+
+impl Default for Search {
+    fn default() -> Self {
+        Self { enabled: true }
+    }
+}
+
 #[derive(Debug, Clone, Deserialize)]
 #[serde(default, deny_unknown_fields)]
 pub struct Nav {
diff --git a/src/lib.rs b/src/lib.rs
@@ -192,14 +192,19 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
             // 설정의 [languages]가 아니라 **실제 페이지의 언어**로 만든다.
             // 언어를 선언하지 않은 사이트도 기본 언어로 색인을 내보내므로,
             // 설정을 기준으로 삼으면 그 사이트에서 색인 링크가 죽는다.
-            .chain(
+            // 색인을 끈 사이트에서는 넣지 않는다 — 존재하지 않는 파일을 알려진
+            // URL로 등록하면 죽은 링크를 살아 있다고 판정하게 된다.
+            .chain(if cfg.search.enabled {
                 pages
                     .iter()
                     .map(|p| p.language.as_str())
                     .collect::<std::collections::BTreeSet<_>>()
                     .into_iter()
-                    .map(|lang| format!("{base}/{}", search::path(lang))),
-            )
+                    .map(|lang| format!("{base}/{}", search::path(lang)))
+                    .collect::<Vec<_>>()
+            } else {
+                Vec::new()
+            })
             .collect(),
     };
     md = md.with_known_urls(known);
@@ -256,14 +261,18 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
             page.out_path.to_string_lossy().replace('\\', "/"),
             html.into_bytes(),
         );
-        index.entry(page.language.clone()).or_default().push(entry);
+        if let Some(entry) = entry {
+            index.entry(page.language.clone()).or_default().push(entry);
+        }
     }
     prof.lap("render");
 
     for (language, entries) in index {
         let json = serde_json::to_vec(&entries)
             .with_context(|| format!("{language} 검색 색인을 만들 수 없습니다"))?;
-        out.files.insert(search::path(&language), json);
+        // `static/`에 같은 이름이 있으면 그쪽이 이긴다. 피드·sitemap·robots과
+        // 같은 규칙이다 — 직접 넣은 색인이 조용히 덮이는 건 어느 쪽이든 나쁘다.
+        out.files.entry(search::path(&language)).or_insert(json);
     }
     prof.lap("search");
 
@@ -412,7 +421,8 @@ fn check_output_collisions(out: &BuildOutput) -> Result<()> {
 }
 
 /// 페이지 HTML과 그 페이지의 검색 색인 행을 함께 낸다. 본문 평문은 렌더 과정에서
-/// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다.
+/// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다. 색인을 끈 사이트에는
+/// 행을 만들지 않는다 — 페이지당 본문 사본 하나가 통째로 사라진다.
 // 렌더 루프 한 곳에서만 부르는 내부 함수라 인자를 묶는 구조체가 이름값을 못 한다.
 #[allow(clippy::too_many_arguments)]
 fn render_page(
@@ -424,7 +434,7 @@ fn render_page(
     sections: minijinja::Value,
     highlight_css: Option<&str>,
     feed_url: Option<&str>,
-) -> Result<(String, search::Entry)> {
+) -> Result<(String, Option<search::Entry>)> {
     let template = select_template(page, site, templates).map_err(Kind::Template.tag())?;
 
     let site_ctx = SiteCtx {
@@ -468,7 +478,7 @@ fn render_page(
 
     let (prev, next) = site.neighbours_of(page);
 
-    let entry = search::Entry {
+    let entry = cfg.search.enabled.then(|| search::Entry {
         t: page.title.clone(),
         d: page.front.description.clone(),
         u: page.url.clone(),
@@ -477,7 +487,7 @@ fn render_page(
             .map(|s| s.title.clone())
             .unwrap_or_default(),
         c: rendered.text.clone(),
-    };
+    });
 
     let page_ctx = PageCtx {
         title: page.title.clone(),
@@ -628,3 +638,69 @@ pub fn display_path(path: &Path) -> String {
         .display()
         .to_string()
 }
+
+#[cfg(test)]
+mod search_toggle_tests {
+    use super::*;
+
+    /// init 뼈대에 설정을 덧붙인 임시 사이트. 반환된 경로는 테스트가 지운다.
+    fn site_with(extra_config: &str, name: &str) -> PathBuf {
+        let dir = std::env::temp_dir().join(format!("sqzass-search-{}-{name}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        init::init(&dir).unwrap();
+        let cfg_path = dir.join(config::CONFIG_FILE);
+        let mut cfg = std::fs::read_to_string(&cfg_path).unwrap();
+        cfg.push_str(extra_config);
+        std::fs::write(&cfg_path, cfg).unwrap();
+        dir
+    }
+
+    fn opts(dir: &Path) -> BuildOptions {
+        BuildOptions {
+            input: dir.to_path_buf(),
+            output: None,
+            drafts: false,
+            base_url: None,
+            profile: false,
+        }
+    }
+
+    #[test]
+    fn search_index_is_generated_by_default() {
+        let dir = site_with("", "on");
+        let out = build_to_memory(&opts(&dir)).unwrap();
+        assert!(
+            out.files.contains_key(&search::path("en")),
+            "실제 파일들: {:?}",
+            out.files.keys().collect::<Vec<_>>()
+        );
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn disabled_search_emits_no_index_at_all() {
+        let dir = site_with("\n[search]\nenabled = false\n", "off");
+        let out = build_to_memory(&opts(&dir)).unwrap();
+        let leaked: Vec<_> = out
+            .files
+            .keys()
+            .filter(|k| k.starts_with("search-"))
+            .collect();
+        assert!(leaked.is_empty(), "색인이 나왔다: {leaked:?}");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn a_static_search_index_wins_over_the_generated_one() {
+        let dir = site_with("", "static-wins");
+        std::fs::create_dir_all(dir.join("static")).unwrap();
+        std::fs::write(dir.join("static/search-en.json"), b"{\"handmade\":true}").unwrap();
+        let out = build_to_memory(&opts(&dir)).unwrap();
+        assert_eq!(
+            out.files.get(&search::path("en")).map(Vec::as_slice),
+            Some(b"{\"handmade\":true}".as_slice()),
+            "생성 색인이 static/ 파일을 덮었다"
+        );
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}