MaxMilton/microdoc

View on GitHub
src/plugin/search.ts

Summary

Maintainability
A
2 hrs
Test Coverage
F
0%
/**
 * Microdoc Search Plugin
 *
 * @see https://microdoc.js.org/#/plugins/search.md
 * docs/plugins/search.md
 */

// FIXME: Search input takes up too much space on narrow screens
//  ↳ Docusaurus use a button on small screens to open a search UI (actually on
//    large screens too but there it looks like a real search input)

// FIXME: Dismiss on click outside the search results dropdown

// TODO: Investigate using different client-side search engine for better search
// result quality and performance characteristics
//  ↳ https://github.com/nextapps-de/flexsearch

// TODO: Results should use the document title (not the menu item title)

import Fuse from 'fuse.js';
import type { S1Node } from 'stage1';
import type { InternalMicrodoc } from '../types';

interface ContentData {
  body: string;
  title: string;
  url: string;
}

const { h, root: urlRoot, $routes } = window.microdoc as InternalMicrodoc;
let popup: ResultListComponent;
let fuse: Fuse<ContentData>;

async function loadContent() {
  const files: [fetchRes: Promise<Response>, title: string, url: string][] = [];
  const content = [];

  $routes.forEach((route) => {
    files.push([fetch(urlRoot + route.path.slice(1)), route.name, route.path]);
  });

  for (const file of files) {
    try {
      // eslint-disable-next-line no-await-in-loop
      const res = await file[0];
      // eslint-disable-next-line no-await-in-loop
      const body = await res.text();

      if (!res.ok) {
        throw new Error(body || `${res.status}`);
      }
      content.push({ body, title: file[1], url: file[2] });
    } catch (error) {
      console.error(error);
    }
  }

  return content;
}

type ResultListComponent = S1Node &
  HTMLDivElement & {
    update: (results?: Fuse.FuseResult<ContentData>[]) => void;
  };
type ResultItemComponent = S1Node & HTMLDivElement;
type SearchComponent = S1Node & HTMLDivElement;

type ResultsRefNodes = {
  list: HTMLUListElement;
};
type ResultItemRefNodes = {
  url: HTMLAnchorElement;
};
type SearchRefNodes = {
  button: HTMLButtonElement;
  input: HTMLInputElement;
};

const resultItemView = h(`
  <li>
    <a #url></a>
  </li>
`);
const resultListView = h(`
  <div class=microdoc-search-results hidden>
    <h3>Search Results</h3>

    <ul #list></ul>
  </div>
`);
// https://github.com/tabler/tabler-icons/blob/master/icons/x.svg
// https://github.com/feathericons/feather/blob/master/icons/search.svg
const searchView = h(`
  <div class="microdoc-search microdoc-header-item">
    <button class=microdoc-button-search #button>
      <svg viewBox="0 0 24 24" class=microdoc-icon-x>
        <line x1=18 y1=6 x2=6 y2=18 />
        <line x1=6 y1=6 x2=18 y2=18 />
      </svg>
    </button>
    <input type=search class=microdoc-search-input placeholder="Search docs..." #input>
    <svg viewBox="0 0 24 24" class=microdoc-icon-search>
      <circle cx=11 cy=11 r=8 />
      <line x1=21 y1=21 x2=16.65 y2=16.65 />
    </svg>
  </div>
`);

function ResultItem(result: Fuse.FuseResult<ContentData>): ResultItemComponent {
  const root = resultItemView.cloneNode(true) as ResultItemComponent;
  const { url } = resultItemView.collect<ResultItemRefNodes>(root);

  // TODO: If the match is in the body, show the relevant part
  // TODO: Highlight the matching part -- data in result.matches

  url.href = result.item.url;
  url.textContent = result.item.title;

  url.__click = () => {
    popup.update();
    // FIXME: Obviously need to do something better
    document.querySelector('.microdoc-search')!.classList.remove('expanded');
  };

  return root;
}

function ResultList(): ResultListComponent {
  const root = resultListView as ResultListComponent;
  const { list } = resultListView.collect<ResultsRefNodes>(root);

  root.update = (results) => {
    list.textContent = '';

    if (!results) {
      root.hidden = true;
      return;
    }

    results.forEach((result) => list.append(ResultItem(result)));

    root.hidden = false;
  };

  return root;
}

function Search(): SearchComponent {
  const root = searchView as SearchComponent;
  const { button, input } = searchView.collect<SearchRefNodes>(root);

  const search = () => {
    const results = fuse.search(input.value);
    (popup ??= root.appendChild(ResultList())).update(results);
  };

  button.__click = () => {
    root.classList.toggle('expanded');
    input.focus();
  };

  input.onfocus = () => {
    if (input.value) {
      search();
    }
  };
  input.oninput = () => {
    if (!fuse) {
      // TODO: If a user tries to search before all the content has been loaded and
      // parsed show some feedback in the UI
      console.warn('Search index not ready yet');
      return;
    }

    if (input.value === '') {
      popup?.update();
    } else {
      search();
    }
  };
  input.onkeydown = (event) => {
    if (event.key === 'Escape') {
      input.value = '';
      popup?.update();
      root.classList.remove('expanded');
    }
  };

  return root;
}

document.querySelector('.microdoc-header')!.append(Search());

loadContent()
  .then((content) => {
    fuse = new Fuse(content, {
      ignoreLocation: true,
      // includeScore: true, // useful for debugging
      includeMatches: true,
      keys: [
        // {
        //   name: 'body',
        //   weight: 0.9,
        // },
        'body',
        'title',
      ],
    });
  })
  .catch(console.error);