diff --git a/README.md b/README.md
index 7220015..e84eb02 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ python book/prepare.py
cd book && quarto preview
```
-`book/prepare.py`는 빌드할 때 [번역 가이드](TRANSLATION_GUIDE.md)의 용어표로 웹북의 '용어집' 페이지를 만들고, `book/postrender.py`는 검색 결과 미리보기의 수식 기호를 읽기 쉽게 정리합니다. `book/prepare.py`는 빌드할 때만 장 제목, 수식 표기, 접힌 코드, 옮긴이 주 상자를 책 형식으로 바꿉니다. 그래서 노트북은 원서와 같은 구조로 두면 됩니다.
+`book/prepare.py`는 빌드할 때 [번역 가이드](TRANSLATION_GUIDE.md)의 용어표로 웹북의 '용어집' 페이지를 만들고, `book/postrender.py`는 렌더링이 끝난 뒤 웹북 검색 색인(`search-index.json`)을 만듭니다. `book/prepare.py`는 빌드할 때만 장 제목, 수식 표기, 접힌 코드, 옮긴이 주 상자를 책 형식으로 바꿉니다. 그래서 노트북은 원서와 같은 구조로 두면 됩니다.
diff --git a/book/_quarto.yml b/book/_quarto.yml
index 3b2ae6b..f174d94 100644
--- a/book/_quarto.yml
+++ b/book/_quarto.yml
@@ -28,15 +28,7 @@ book:
repo-subdir: causal-inference-for-the-brave-and-true
repo-actions: [issue]
favicon: assets/causal-lab-mark.png
- search:
- location: sidebar
- type: textbox
- limit: 50
- collapse-after: 3
- copy-button: true
- show-item-context: true
- keyboard-shortcut: ["/", "k"]
- reader-mode: true
+ search: false
open-graph:
image: assets/og-image.png
locale: ko_KR
@@ -127,6 +119,7 @@ format:
include-after-body:
- sidebar-resize.html
- chapter-toc.html
+ - site-search.html
include-in-header:
- text: |
diff --git a/book/index.qmd b/book/index.qmd
index cbfd51a..4bd4c2f 100644
--- a/book/index.qmd
+++ b/book/index.qmd
@@ -91,7 +91,7 @@ aliases:
각 장은 원서의 주피터 노트북을 그대로 옮긴 것이라 코드와 출력이 원서와 같습니다. 노트북 파일은 [GitHub 저장소](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/tree/main/causal-inference-for-the-brave-and-true)에서 내려받아 직접 실행해 볼 수 있습니다. 그래프를 그리는 코드처럼 길어서 접어 둔 셀은 **코드**를 눌러 펼치면 됩니다.
-용어는 같은 저자의 책을 옮긴 『실무로 통하는 인과추론 with 파이썬』(한빛미디어)과 맞췄습니다. 두 책을 함께 보셔도 용어가 헷갈리지 않습니다. 용어의 원어와 그 용어를 다루는 장은 [용어집](glossary.html)에서 찾을 수 있습니다. 검색창(단축키 `/`)에 한국어나 영어 용어를 넣어도 됩니다. 번역 원칙과 용어표는 [번역 가이드](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/blob/main/TRANSLATION_GUIDE.md)에 정리해 두었습니다.
+용어는 같은 저자의 책을 옮긴 『실무로 통하는 인과추론 with 파이썬』(한빛미디어)과 맞췄습니다. 두 책을 함께 보셔도 용어가 헷갈리지 않습니다. 용어의 원어와 그 용어를 다루는 장은 [용어집](glossary.html)에서 찾을 수 있습니다. 왼쪽 위 검색(단축키 `/` 또는 `Ctrl+K`)에 한국어나 영어 용어를 넣어도 됩니다. 띄어쓰기는 달라도 찾아 줍니다. 번역 원칙과 용어표는 [번역 가이드](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/blob/main/TRANSLATION_GUIDE.md)에 정리해 두었습니다.
## 함께 만들어 주세요
diff --git a/book/postrender.py b/book/postrender.py
index 38c92d5..e8637df 100644
--- a/book/postrender.py
+++ b/book/postrender.py
@@ -1,11 +1,14 @@
-"""렌더링 뒤처리: 검색 색인(search.json)의 수식 기호를 읽기 쉽게 바꾼다.
+"""렌더링 뒤처리: 웹북 검색 색인(search-index.json)을 만든다.
-검색 결과 미리보기는 수식을 그리지 않으므로 `\\(Y_1 \\perp T\\)` 같은 TeX 원문이 그대로 보인다.
-구분 기호를 지우고 자주 쓰는 기호만 유니코드로 바꾼다. 검색 대상 문자열에만 적용되고 본문은 그대로다.
+렌더링된 각 장의 본문을 절(h2/h3) 단위로 나눠 평문으로 모은다. 코드 셀과 출력은 빼고,
+수식은 `\\(Y_1 \\perp T\\)` 대신 `Y_1 ⊥ T`처럼 읽히는 평문으로 바꾼다.
+검색 화면(book/site-search.html)이 이 파일을 읽어 띄어쓰기와 상관없이 찾는다.
"""
+import html
import json
import os
import re
+from html.parser import HTMLParser
from pathlib import Path
OUT = Path(os.environ.get("QUARTO_PROJECT_OUTPUT_DIR", Path(__file__).parent / "_book"))
@@ -19,7 +22,7 @@
def tex_to_plain(tex):
tex = re.sub(r"\\[!,;: ]", "", tex)
- tex = re.sub(r"\\(?:hat|bar|tilde|mathbb|mathcal|mathrm|text|operatorname|underbrace|overbrace|left|right|big|Big|frac|displaystyle)\b", "", tex)
+ tex = re.sub(r"\\(?:hat|bar|tilde|mathbb|mathcal|mathrm|text|operatorname|underbrace|overbrace|left|right|big|Big|frac|dfrac|tfrac|displaystyle|mathbf|boldsymbol)\b", "", tex)
tex = re.sub(r"\\([A-Za-z]+)", lambda m: SYMBOLS.get(m.group(1), m.group(1)), tex)
tex = re.sub(r"⊥(\s*⊥)+", "⊥", tex)
tex = tex.replace("{", "").replace("}", "").replace("\\", "")
@@ -31,19 +34,109 @@ def clean(text):
return re.sub(r"\\\((.*?)\\\)|\\\[(.*?)\\\]", lambda m: tex_to_plain(m.group(1) or m.group(2) or ""), text, flags=re.S)
+SKIP_TAGS = {"script", "style", "svg", "pre", "nav", "button", "noscript", "header"}
+SKIP_CLASSES = ("cell", "code-copy-outer-scaffold", "sourceCode", "inline-toc", "hero-actions", "hero-links", "term-link", "screen-reader-only")
+TEXT_BREAK = {"p", "li", "td", "th", "dt", "dd", "figcaption", "blockquote", "h4", "h5", "div", "tr"}
+VOID = {"br", "img", "hr", "meta", "link", "input", "source", "wbr"}
+
+
+class PageParser(HTMLParser):
+ """main#quarto-document-content 안의 글을 절 단위로 모은다."""
+
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.in_main = False
+ self.skip = 0
+ self.stack = []
+ self.heading = None
+ self.sections = [{"id": "", "title": "", "text": []}]
+
+ def handle_starttag(self, tag, attrs):
+ if tag in VOID:
+ if tag == "br" and self.in_main and not self.skip:
+ self.sections[-1]["text"].append(" ")
+ return
+ a = dict(attrs)
+ cls = (a.get("class") or "").split()
+ mark = None
+ if tag == "main" and a.get("id") == "quarto-document-content":
+ self.in_main = True
+ mark = "main"
+ elif self.in_main:
+ if self.skip or tag in SKIP_TAGS or any(c in cls for c in SKIP_CLASSES):
+ self.skip += 1
+ mark = "skip"
+ elif tag == "section" and a.get("id") and ("level2" in cls or "level3" in cls):
+ self.sections.append({"id": a["id"], "title": "", "text": []})
+ elif tag in ("h2", "h3"):
+ self.heading = []
+ mark = "heading"
+ self.stack.append((tag, mark))
+
+ def handle_endtag(self, tag):
+ if tag in VOID:
+ return
+ while self.stack:
+ t, mark = self.stack.pop()
+ if mark == "skip":
+ self.skip -= 1
+ elif mark == "heading" and self.heading is not None:
+ title = " ".join("".join(self.heading).split())
+ if not self.sections[-1]["title"]:
+ self.sections[-1]["title"] = title
+ self.heading = None
+ elif mark == "main":
+ self.in_main = False
+ if t == tag:
+ break
+ if self.in_main and not self.skip and tag in TEXT_BREAK:
+ self.sections[-1]["text"].append(" ")
+
+ def handle_data(self, data):
+ if not self.in_main or self.skip:
+ return
+ (self.heading if self.heading is not None else self.sections[-1]["text"]).append(data)
+
+
+def page_title(raw):
+ m = re.search(r"
(.*?)", raw, re.S)
+ title = html.unescape(m.group(1)) if m else ""
+ title = re.sub(r"\s+[–|]\s+Python으로 하는 인과추론\s*$", "", title)
+ return " ".join(title.replace("\u00a0", " ").split())
+
+
+def build_index():
+ entries = []
+ for path in sorted(OUT.glob("*.html")):
+ if path.name in ("404.html", "search.html", "landing-page.html"):
+ continue
+ raw = path.read_text(encoding="utf-8")
+ if "quarto-document-content" not in raw:
+ continue
+ parser = PageParser()
+ parser.feed(raw)
+ title = page_title(raw)
+ for sec in parser.sections:
+ text = clean(" ".join("".join(sec["text"]).split()))
+ text = re.sub(r"\s+([.,)])", r"\1", text)
+ if not text and not sec["title"]:
+ continue
+ if sec["title"] == "기여하기":
+ continue # 모든 장에 같은 문단이라 검색 결과만 어지럽힌다
+ entries.append({
+ "u": path.name + (f"#{sec['id']}" if sec["id"] else ""),
+ "p": title,
+ "s": clean(sec["title"]),
+ "t": text,
+ })
+ (OUT / "search-index.json").write_text(json.dumps(entries, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
+ return len(entries)
+
+
def main():
- path = OUT / "search.json"
- if not path.exists():
- return
- data = json.loads(path.read_text(encoding="utf-8"))
- for item in data:
- for key in ("text", "section", "title"):
- if isinstance(item.get(key), str):
- item[key] = clean(item[key])
- # 검색 결과의 경로 표시(crumbs)에 장 번호 HTML이 그대로 찍히는 문제
- if isinstance(item.get("crumbs"), list):
- item["crumbs"] = [re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", c)).strip() for c in item["crumbs"]]
- path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
+ print(f"search index: {build_index()} sections")
+ # Quarto 기본 검색은 쓰지 않으므로 그 색인은 지운다.
+ (OUT / "search.json").unlink(missing_ok=True)
if __name__ == "__main__":
diff --git a/book/sidebar-resize.html b/book/sidebar-resize.html
index d3ddf3d..e3ba2d8 100644
--- a/book/sidebar-resize.html
+++ b/book/sidebar-resize.html
@@ -1,20 +1,28 @@
diff --git a/book/site-search.html b/book/site-search.html
new file mode 100644
index 0000000..0ae3567
--- /dev/null
+++ b/book/site-search.html
@@ -0,0 +1,196 @@
+