예제
KO

검색

Hwaro는 Fuse.js와 함께 사용할 수 있는 클라이언트 사이드 검색 인덱스를 생성합니다.

설정

config.toml에서 활성화합니다.

[search]
enabled = true
format = "fuse_json"
fields = ["title", "content", "description", "tags", "url", "section"]
filename = "search.json"
exclude = ["/private", "/drafts"]
타입 기본값 설명
enabled bool false 검색 인덱스 생성 여부
format string "fuse_json" 검색 인덱스 포맷
fields array ["title", "content"] 인덱스에 포함할 필드 — 기본값은 titlecontent뿐 (url은 항상 추가)
filename string "search.json" 출력 파일 이름
exclude array [] 검색 인덱스에서 제외할 경로(접두사)
tokenize_cjk bool false CJK 바이그램 토큰화 활성화
shards string "none" 인덱스를 지연 로드 가능한 샤드로 분할: "section", "language", "section-language"샤드 인덱스 참고
single_file bool true shards 사용 시 기존 search.json도 함께 생성; false면 샤드만 생성
content_max_length int 0 0보다 크면 각 항목의 content를 단어 경계에서 해당 글자 수로 자름; 0이면 전체 본문 유지

생성 파일

활성화하면 Hwaro가 /search.json을 생성합니다(filename으로 변경 가능).

[
  {
    "title": "My Post",
    "url": "/blog/my-post/",
    "content": "Page content...",
    "description": "Post description",
    "section": "blog",
    "tags": ["tutorial"]
  }
]

인덱싱되는 필드

fields에 나열한 필드만 출력됩니다(url은 항상 포함).

필드 설명
title 페이지 제목
url 페이지 URL
content 페이지 본문(fields"content"가 있을 때)
description 페이지 설명
section 섹션 이름
tags 페이지 태그

클라이언트 사이드 구현

Fuse.js 사용

템플릿에 추가합니다.

<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
<script>
let searchIndex = [];

// Load index
fetch('/search.json')
  .then(res => res.json())
  .then(data => {
    searchIndex = data;
  });

// Initialize Fuse.js
function search(query) {
  const fuse = new Fuse(searchIndex, {
    keys: ['title', 'content', 'description', 'tags'],
    threshold: 0.3
  });
  return fuse.search(query);
}
</script>

검색 폼

<form id="search-form">
  <input type="search" id="search-input" placeholder="Search...">
</form>

<div id="search-results"></div>

<script>
const input = document.getElementById('search-input');
const results = document.getElementById('search-results');

input.addEventListener('input', (e) => {
  const query = e.target.value;
  if (query.length < 2) {
    results.innerHTML = '';
    return;
  }
  
  const matches = search(query);
  results.innerHTML = matches
    .slice(0, 10)
    .map(m => `
      <a href="${m.item.url}">
        <h3>${m.item.title}</h3>
        <p>${m.item.description || ''}</p>
      </a>
    `)
    .join('');
});
</script>

CJK 검색 지원

중국어·일본어·한국어 콘텐츠가 있는 사이트라면 CJK 토큰화를 켜서 검색 정확도를 높일 수 있습니다. CJK 언어는 단어 사이에 공백이 없는 경우가 많아 검색 라이브러리가 텍스트를 제대로 토큰화하기 어렵습니다.

이 옵션을 켜면 연속된 CJK 문자를 겹치는 바이그램(2글자 쌍)으로 분할하므로, 긴 텍스트 안에서도 검색어가 매칭됩니다.

예: "검색엔진""검색 색엔 엔진" (이제 검색어 "검색"이 매칭됨)

설정

[search]
enabled = true
tokenize_cjk = true
타입 기본값 설명
tokenize_cjk bool false 검색 인덱스에 CJK 바이그램 토큰화 적용

동작 방식

참고

페이지 제외

프론트 매터

프론트 매터로 개별 페이지를 검색에서 제외합니다.

+++
title = "Terms of Service"
in_search_index = false
+++

설정

config.toml로 섹션이나 경로 전체를 제외합니다.

[search]
exclude = ["/private", "/drafts"]

필드 선택

fields를 지정해 검색 인덱스에 들어갈 필드를 제어합니다.

[search]
enabled = true
fields = ["title", "description", "tags", "url"]

사용 가능한 필드: title, content, description, tags, url, section.

fields에서 content를 빼면 대규모 사이트에서 인덱스 파일 크기가 크게 줄어듭니다.

성능 팁

대규모 사이트

페이지가 많은 사이트라면:

  1. fields에서 "content"를 제거해 인덱스 크기를 줄입니다
  2. Fuse.js의 ignoreLocation 옵션을 사용합니다
  3. 디바운스 검색을 구현합니다
function debounce(fn, delay) {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => fn(...args), delay);
  };
}

input.addEventListener('input', debounce((e) => {
  // search logic
}, 200));

지연 로딩

검색창에 포커스가 왔을 때만 인덱스를 불러옵니다.

let indexLoaded = false;

input.addEventListener('focus', async () => {
  if (indexLoaded) return;
  const res = await fetch('/search.json');
  searchIndex = await res.json();
  indexLoaded = true;
});

샤드 인덱스

search.json 하나는 사이트가 커질수록 함께 커지고, 방문자는 첫 검색 전에 파일 전체를 내려받아야 합니다. 샤딩은 인덱스를 여러 JSON 파일과 매니페스트로 나누어, 클라이언트가 필요한 것만(현재 섹션 먼저, 또는 언어별로) 불러올 수 있게 합니다.

[search]
enabled = true
shards = "section"          # "section" | "language" | "section-language"
single_file = false         # 샤드만 생성 (기본값 true는 search.json도 유지)
content_max_length = 500    # 선택: 각 항목의 content 길이 제한
모드 샤드 id 예시
"section" 최상위 콘텐츠 섹션마다 하나; 섹션 밖의 페이지는 _root blog, docs, _root
"language" 언어마다 하나(다국어 사이트); 기본 언어는 자신의 코드 사용 en, ko
"section-language" 언어, 그다음 섹션 en/blog, ko/blog, ko/_root

중첩 섹션은 최상위 섹션으로 합쳐집니다. blog/news/post.mdblog 샤드에 들어갑니다. 기존 인덱스의 포함 규칙(fields, exclude, in_search_index = false, 초안, render = false, 언어별 build_search_index, tokenize_cjk)은 그대로 적용됩니다.

생성 파일

public/
├── search.json          # single_file = false가 아니면 생성
└── search/
    ├── index.json       # 매니페스트
    ├── _root.json
    ├── blog.json
    └── docs.json        # 중첩 id는 디렉터리 사용: search/ko/blog.json

각 샤드는 search.json과 완전히 동일한 항목 스키마를 가진 JSON 배열입니다(format 설정과 무관하게 샤드는 항상 JSON입니다. *_javascript 래퍼는 <script src> 로딩 전용입니다). search/index.json은 전체 구조를 설명합니다.

{
  "version": 1,
  "fields": ["title", "content", "url", "lang"],
  "shards": [
    {"id": "_root", "url": "/search/_root.json", "language": null, "section": "", "count": 2, "bytes": 200},
    {"id": "blog",  "url": "/search/blog.json",  "language": null, "section": "blog", "count": 42, "bytes": 12345}
  ]
}

Fuse.js로 샤드 지연 로드

매니페스트를 한 번 받은 뒤 필요할 때 샤드를 불러옵니다. 전역 검색창은 모든 샤드를, 섹션 인식 검색창은 현재 섹션 샤드를 먼저 받고 나머지는 백그라운드에서 받습니다.

<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
<script>
const loaded = new Map();       // 샤드 id → 항목 배열
let manifest = null;
let fuse = null;

async function loadManifest() {
  if (manifest) return manifest;
  manifest = await (await fetch('/search/index.json')).json();
  return manifest;
}

async function loadShard(shard) {
  if (loaded.has(shard.id)) return;
  loaded.set(shard.id, await (await fetch(shard.url)).json());
  fuse = new Fuse([...loaded.values()].flat(), {
    keys: ['title', 'content', 'description', 'tags'],
    threshold: 0.3,
    ignoreLocation: true
  });
}

// 이 페이지에 필요한 샤드는? 문서 언어와 URL 첫 세그먼트로
// 매니페스트를 거르고, 해당 없으면 전체를 불러옵니다.
async function loadRelevantShards() {
  const { shards } = await loadManifest();
  const lang = (document.documentElement.lang || '').split('-')[0];
  const section = location.pathname.split('/').filter(Boolean)[0] || '';
  const local = shards.filter(s =>
    (s.language === null || s.language === lang) &&
    (s.section === null || s.section === section));
  await Promise.all((local.length ? local : shards).map(loadShard));
  // 첫 결과를 막지 않고 나머지 샤드를 미리 받아 둡니다.
  shards.filter(s => !loaded.has(s.id)).forEach(s => loadShard(s));
}

function search(query) {
  return fuse ? fuse.search(query) : [];
}

document.getElementById('search-input').addEventListener('focus', loadRelevantShards, { once: true });
</script>

전역 검색만 필요하면 loadRelevantShards 대신 shards.map(loadShard)를 사용하세요. 로드 이후는 위의 단일 파일 예제와 같은 Fuse.js 코드입니다.

대안: Pagefind

더 큰 사이트라면 Pagefind를 고려해 볼 만합니다.

# 빌드 후 실행
npx pagefind --site public

빌드 후 훅으로 설정에 추가합니다.

[build]
hooks.post = ["npx pagefind --site public"]

함께 보기