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);
+ }
+}
|