sqzass.git

sqzass

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

템플릿에 site.search 를 준다 — 색인 URL이거나, 껐으면 없음

[search] enabled를 끈 사이트에서 검색 UI가 든 테마는 존재하지 않는 색인을
가리켰다. 템플릿이 설정을 알 길이 없어서다. site.feed와 같은 꼴로 노출한다:
켜져 있으면 이 언어의 색인 URL, 꺼져 있으면 없음 — if는 방어가 아니라
테마가 두 경우 모두에서 동작하는 방법 그 자체다.

bool이 아니라 URL인 이유: 색인 파일명(search-<언어>.json)은 엔진 지식이다.
지금까지 base.html이 두 곳에서 `search-`에 언어 코드를 손으로 이어
붙였는데, 그 조립을 템플릿마다 반복시키는 것이 바로 이 필드가 없애는
것이다.

base.html의 버튼·팔레트·푸터 링크·search.js 로드가 전부 이 뒤로 들어간다.
search.js의 기존 null 가드 덕에 끈 사이트에서도 JS는 조용히 물러난다.
404 페이지에도 기본 언어 색인으로 준다 — 길 잃은 독자가 빠져나가는 길이다.

Diffstat:
Mdocs/content/features/search.ko.md | 14++++++++++++++
Mdocs/content/features/search.md | 14++++++++++++++
Mdocs/content/templates/data.ko.md | 1+
Mdocs/content/templates/data.md | 1+
Mdocs/templates/base.html | 12++++++++++--
Msrc/lib.rs | 31+++++++++++++++++++++++++++++++
Msrc/render.rs | 5+++++
7 files changed, 76 insertion(+), 2 deletion(-)

diff --git a/docs/content/features/search.ko.md b/docs/content/features/search.ko.md
@@ -58,6 +58,20 @@ enabled = false
 
 색인 생성을 통째로 건너뜁니다.
 
+템플릿에는 같은 스위치가 `site.search`로 옵니다 — 현재 언어의 색인 URL이거나,
+꺼져 있으면 없습니다:
+
+```html
+{% if site.search %}
+<button id="search-trigger" data-index="{{ site.search }}">…</button>
+{% endif %}
+```
+
+`site.feed`와 같은 규칙입니다. 이 `if`는 방어가 아니라 검색을 끈 사이트에서도
+테마가 동작하게 하는 방법 그 자체입니다. 이 사이트의 검색 버튼과 팔레트, 푸터
+링크가 전부 이 뒤에 있습니다 — 그리고 URL은 템플릿이 `search-`에 언어 코드를
+이어 붙여 만드는 게 아니라 빌드가 줍니다.
+
 ## 순위
 
 질의의 모든 단어가 그 행 어딘가에 나타나야 합니다. OR가 아니라 AND입니다. 제목에서
diff --git a/docs/content/features/search.md b/docs/content/features/search.md
@@ -61,6 +61,20 @@ enabled = false
 
 skips the index entirely.
 
+Templates see the same switch as `site.search` — the current language's index
+URL, or nothing:
+
+```html
+{% if site.search %}
+<button id="search-trigger" data-index="{{ site.search }}">…</button>
+{% endif %}
+```
+
+The same rule as `site.feed`: the `if` is not defensive, it is how a theme
+works on sites that turned search off. This site's search button, the palette
+and the footer link all sit behind it — and the URL comes from the build, not
+from the template gluing `search-` to a language code.
+
 ## Ranking
 
 Every term in the query must appear somewhere in a row — an AND, not an OR.
diff --git a/docs/content/templates/data.ko.md b/docs/content/templates/data.ko.md
@@ -17,6 +17,7 @@ toc = true
 | `site.language` | 지금 렌더 중인 페이지의 언어. |
 | `site.sections` | **이 언어의** 최상위 섹션들. |
 | `site.highlight_css` | 생성된 하이라이트 스타일시트 URL. 강조가 꺼져 있으면 없습니다. |
+| `site.search` | 이 언어의 검색 색인 URL. `[search] enabled = false`면 없습니다. |
 
 `site.sections`에는 현재 언어의 트리만 담깁니다. 내비게이션이 안전한 이유가
 이것입니다. 번역되지 않은 페이지는 여기 없으므로 그 링크를 그릴 수가 없습니다.
diff --git a/docs/content/templates/data.md b/docs/content/templates/data.md
@@ -17,6 +17,7 @@ Two objects are in scope on every page: `site` and `page`.
 | `site.language` | The language of the page being rendered. |
 | `site.sections` | Top-level sections **in this language**. |
 | `site.highlight_css` | URL of the generated highlight stylesheet, or nothing if highlighting is off. |
+| `site.search` | URL of this language's search index, or nothing with `[search] enabled = false`. |
 
 `site.sections` contains only the current language's tree, which is what makes
 navigation safe: an untranslated page is not in it, so a link to it cannot be
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -78,12 +78,14 @@
   {#- 크롬은 전부 같은 컨트롤이다. 하나만 텍스트 링크로 두면 그 하나가 고장난
       것처럼 보인다. #}
   <div class="tools">
+    {%- if site.search %}
     <button id="search-trigger" class="chip search-trigger" type="button"
-            data-index="{{ site.base_path }}/search-{{ page.language }}.json">
+            data-index="{{ site.search }}">
       <span>{{ t("search") }}</span>
       {#- 라벨은 search.js가 읽는 사람의 수정자 키로 바꿔 쓴다. 이건 JS가 없을 때 값. #}
       <kbd>Ctrl /</kbd>
     </button>
+    {%- endif %}
     {%- for lang in page.translations %}
     <a class="chip" href="{{ lang.url }}" hreflang="{{ lang.code }}">{{ lang.name }}</a>
     {%- endfor %}
@@ -184,7 +186,9 @@
         <li><a href="{{ site.base_path }}/sitemap.xml">sitemap.xml</a></li>
         <li><a href="{{ site.base_path }}/robots.txt">robots.txt</a></li>
         <li><a href="{{ site.base_path }}/llms.txt">llms.txt</a></li>
-        <li><a href="{{ site.base_path }}/search-{{ page.language }}.json">{{ t("footer_search_index") }}</a></li>
+        {%- if site.search %}
+        <li><a href="{{ site.search }}">{{ t("footer_search_index") }}</a></li>
+        {%- endif %}
       </ul>
     </div>
   </div>
@@ -192,6 +196,7 @@
   <p class="colophon-note">{{ t("built_with") }}</p>
 </footer>
 
+{%- if site.search %}
 <dialog id="search-dialog" class="search-dialog" aria-label="{{ t("search_the_docs") }}">
   <div class="search-panel">
     <input type="search" autocomplete="off" spellcheck="false" aria-label="{{ t("search_the_docs") }}">
@@ -202,6 +207,7 @@
     </div>
   </div>
 </dialog>
+{%- endif %}
 
 <script>
 document.querySelector(".theme-toggle").addEventListener("click", function () {
@@ -211,7 +217,9 @@ document.querySelector(".theme-toggle").addEventListener("click", function () {
   try { localStorage.setItem("theme", next); } catch (e) {}
 });
 </script>
+{%- if site.search %}
 <script src="{{ asset("js/search.js") }}" defer></script>
+{%- endif %}
 <script src="{{ asset("js/ui.js") }}" defer
         data-copy="{{ t("copy") }}" data-copied="{{ t("copied") }}"></script>
 </body>
diff --git a/src/lib.rs b/src/lib.rs
@@ -445,6 +445,10 @@ fn render_page(
         language: page.language.clone(),
         sections,
         feed: feed_url.map(str::to_string),
+        search: cfg
+            .search
+            .enabled
+            .then(|| format!("{}/{}", cfg.base_path(), search::path(&page.language))),
         highlight_css: highlight_css.map(str::to_string),
     };
 
@@ -544,6 +548,11 @@ fn render_standalone(
         language: language.clone(),
         sections,
         feed: None,
+        // 404에도 검색은 준다 — 길 잃은 독자가 빠져나가는 길이다.
+        search: cfg
+            .search
+            .enabled
+            .then(|| format!("{}/{}", cfg.base_path(), search::path(&language))),
         highlight_css: highlight_css.map(str::to_string),
     };
 
@@ -690,6 +699,28 @@ mod search_toggle_tests {
         let _ = std::fs::remove_dir_all(&dir);
     }
 
+    #[test]
+    fn templates_see_the_index_url_or_nothing() {
+        // `site.search`는 `site.feed`와 같은 규칙이다 — 켜져 있으면 이 언어의
+        // 색인 URL, 꺼져 있으면 없음. 테마가 두 경우 모두에서 동작하는 근거다.
+        let tpl = "{% if site.search %}INDEX={{ site.search }}{% else %}NO-SEARCH{% endif %}";
+
+        let on = site_with("", "ctx-on");
+        std::fs::write(on.join("templates/page.html"), tpl).unwrap();
+        let html = build_to_memory(&opts(&on)).unwrap().files["index.html"].clone();
+        let html = String::from_utf8(html).unwrap();
+        assert!(html.contains("INDEX=/search-en.json"), "실제: {html}");
+
+        let off = site_with("\n[search]\nenabled = false\n", "ctx-off");
+        std::fs::write(off.join("templates/page.html"), tpl).unwrap();
+        let html = build_to_memory(&opts(&off)).unwrap().files["index.html"].clone();
+        let html = String::from_utf8(html).unwrap();
+        assert!(html.contains("NO-SEARCH"), "실제: {html}");
+
+        let _ = std::fs::remove_dir_all(&on);
+        let _ = std::fs::remove_dir_all(&off);
+    }
+
     #[test]
     fn a_static_search_index_wins_over_the_generated_one() {
         let dir = site_with("", "static-wins");
diff --git a/src/render.rs b/src/render.rs
@@ -220,6 +220,11 @@ pub struct SiteCtx {
     /// 이 언어의 Atom 피드 URL. 날짜 있는 페이지가 하나도 없으면 `None`이고,
     /// 그때 템플릿은 자동 발견 링크를 넣지 않으면 된다.
     pub feed: Option<String>,
+    /// 이 언어의 검색 색인 URL. `[search] enabled = false`면 `None`이고,
+    /// 그때 템플릿은 검색 UI를 넣지 않으면 된다 — `feed`와 같은 규칙이다.
+    /// URL 조립을 템플릿에 맡기지 않는 이유이기도 하다: 색인 파일명은 엔진
+    /// 지식이라, 템플릿이 손으로 만들면 끈 사이트에서 죽은 URL이 된다.
+    pub search: Option<String>,
     /// 생성된 하이라이트 스타일시트 URL. 강조가 꺼져 있으면 `None`.
     pub highlight_css: Option<String>,
 }