---
source: crates/ox_content_search/src/runtime.rs
expression: module
---
// Search module generated by ox-content
const searchOptions = {"enabled":true,"limit":12,"prefix":false,"placeholder":"Find docs","hotkey":"k"};
const BM25_K1 = 1.2;
const BM25_B = 0.75;
const MIN_PREFIX_MATCH_LENGTH = 2;
const SNIPPET_CONTEXT_CHARS = 50;
const SNIPPET_MAX_CHARS = 150;
const SNIPPET_ELLIPSIS = "...";

let searchIndex = null;
let indexPromise = null;

function parseScopedQuery(query) {
  const scopes = [];
  const terms = [];

  for (const part of query.trim().split(/\s+/).filter(Boolean)) {
    if (part.startsWith("@") && part.length > 1) {
      scopes.push(part.slice(1).toLowerCase());
    } else {
      terms.push(part);
    }
  }

  return {
    text: terms.join(" ").trim(),
    scopes: [...new Set(scopes)],
  };
}

function getScopesForDoc(doc) {
  const source = (doc.id || doc.url || "").replace(/^\/+/, "").toLowerCase();
  const segments = source.split("/").filter(Boolean);

  if (segments.length <= 1) {
    return [];
  }

  const scopes = [];
  let current = "";
  for (const segment of segments.slice(0, -1)) {
    current = current ? current + "/" + segment : segment;
    scopes.push(current);
  }

  return scopes;
}

function matchesScopes(doc, scopes) {
  if (!scopes.length) {
    return true;
  }

  // Scope checks run once per posting during scoring. Keep the transformation
  // from doc id/url to cumulative scope strings isolated here so generated
  // runtimes can cache it without touching BM25 scoring.
  const docScopes = new Set(getScopesForDoc(doc));
  return scopes.some((scope) => docScopes.has(scope));
}

function tokenizeQuery(text) {
  const tokens = [];
  let current = "";

  for (const char of text) {
    const isCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(char);

    if (isCjk) {
      if (current) {
        tokens.push(current.toLowerCase());
        current = "";
      }
      tokens.push(char);
    } else if (/[a-zA-Z0-9_]/.test(char)) {
      current += char;
    } else if (current) {
      tokens.push(current.toLowerCase());
      current = "";
    }
  }

  if (current) {
    tokens.push(current.toLowerCase());
  }

  return tokens;
}

function computeIdf(df, docCount) {
  return Math.log((docCount - df + 0.5) / (df + 0.5) + 1.0);
}

function getFieldBoost(field) {
  switch (field) {
    case "Title":
      return 10.0;
    case "Heading":
      return 5.0;
    case "Body":
      return 1.0;
    case "Code":
      return 0.5;
    default:
      return 1.0;
  }
}

async function loadIndex() {
  if (searchIndex) return searchIndex;
  if (indexPromise) return indexPromise;

  indexPromise = fetch("/docs/search-index.json")
    .then((res) => res.json())
    .then((data) => {
      searchIndex = data;
      return data;
    })
    .catch((err) => {
      console.error("[ox-content] Failed to load search index:", err);
      return null;
    });

  return indexPromise;
}

export async function search(query, options = {}) {
  const index = await loadIndex();

  if (!index) {
    return [];
  }

  const parsedQuery = parseScopedQuery(query);

  if (!parsedQuery.text && parsedQuery.scopes.length === 0) {
    return [];
  }

  const limit = options.limit ?? searchOptions.limit;
  const prefix = options.prefix ?? searchOptions.prefix;
  const tokens = tokenizeQuery(parsedQuery.text);

  const docScores = new Map();

  if (tokens.length === 0) {
    index.documents.forEach((doc, docIdx) => {
      if (matchesScopes(doc, parsedQuery.scopes)) {
        docScores.set(docIdx, { score: 0, matches: new Set() });
      }
    });
  }

  const scoreTerm = (term) => {
    const postings = index.index[term] || [];
    const df = index.df[term] || 1;
    const idf = computeIdf(df, index.doc_count);

    for (const posting of postings) {
      const doc = index.documents[posting.doc_idx];
      if (!doc) continue;
      if (!matchesScopes(doc, parsedQuery.scopes)) continue;

      const docLen = doc.body.length;
      const tf = posting.tf;
      const boost = getFieldBoost(posting.field);
      const score =
        idf *
        ((tf * (BM25_K1 + 1.0)) /
          (tf + BM25_K1 * (1.0 - BM25_B + (BM25_B * docLen) / index.avg_dl))) *
        boost;

      if (!docScores.has(posting.doc_idx)) {
        docScores.set(posting.doc_idx, { score: 0, matches: new Set() });
      }
      const entry = docScores.get(posting.doc_idx);
      entry.score += score;
      entry.matches.add(term);
    }
  };

  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    const isLast = i === tokens.length - 1;

    if (prefix && isLast && token.length >= MIN_PREFIX_MATCH_LENGTH) {
      // Only the active final token expands across the vocabulary. Completed
      // tokens score exact postings, keeping multi-word queries from
      // multiplying prefix scan cost by token count.
      for (const term in index.index) {
        if (term.startsWith(token)) {
          scoreTerm(term);
        }
      }
    } else if (index.index[token]) {
      scoreTerm(token);
    }
  }

  const results = Array.from(docScores.entries())
    .sort(([leftDocIdx, leftData], [rightDocIdx, rightData]) => {
      const scoreOrder = rightData.score - leftData.score;
      if (scoreOrder !== 0) return scoreOrder;

      const leftDoc = index.documents[leftDocIdx];
      const rightDoc = index.documents[rightDocIdx];
      return (leftDoc?.title || "").localeCompare(rightDoc?.title || "");
    })
    .slice(0, limit)
    .map(([docIdx, data]) => {
      // Snippets require lowercasing and scanning the document body, so they
      // are built only after ranking and limiting rather than for every scored
      // candidate.
      const doc = index.documents[docIdx];
      const matches = Array.from(data.matches);
      const scopes = getScopesForDoc(doc);

      let snippet = "";
      if (doc.body) {
        const bodyLower = doc.body.toLowerCase();
        let firstPos = -1;
        for (const match of matches) {
          const pos = bodyLower.indexOf(match);
          if (pos !== -1 && (firstPos === -1 || pos < firstPos)) {
            firstPos = pos;
          }
        }

        const start = firstPos === -1 ? 0 : Math.max(0, firstPos - SNIPPET_CONTEXT_CHARS);
        const end = Math.min(doc.body.length, start + SNIPPET_MAX_CHARS);
        snippet = doc.body.slice(start, end);
        if (start > 0) snippet = SNIPPET_ELLIPSIS + snippet;
        if (end < doc.body.length) snippet = snippet + SNIPPET_ELLIPSIS;
      }

      return {
        id: doc.id,
        title: doc.title,
        url: doc.url,
        score: data.score,
        matches,
        snippet,
        scopes,
      };
    });

  return results;
}

export { searchOptions };
export default { search, searchOptions, loadIndex };
