devpage 0.2.2

Live documents from a repository: sites, docs and slides from Markdown, and UI designs drawn from code — with comments, link checking and PDF
import { useEffect, useId, useRef, useState } from "react";

// A site's sidebar: devpage's own chrome, not a widget for pages. The server
// renders the same markup (src/render.rs `sidebar`) and the tree as JSON; the
// page script has React take the sidebar over from that markup and keeps it
// mounted across page swaps, giving it each fresh page's tree and address.
//
// Collapsed: the branch down to the page shown is open, its own children
// included; a chevron opens or closes any entry with children. What the
// reader opens is remembered per document in localStorage — the page's
// inline script opens those entries before the first paint, as this does.
//
// The search box at its top asks /__devpage/search as the reader types;
// while it holds text, the results take the tree's place, grouped by page:
// each page's row, its sections under it, then how many more it holds.
// `/` or ⌘K / Ctrl+K focuses it; arrows move through every row, Enter opens
// one through the page swap, Escape or the ⓧ button clears it. A row's link
// carries the words it matched (`data-found`): the page script marks them
// where the row opens.

// `home`: the page's position; a second listing of a page is a plain link.
type Entry = { label: string; href: string; home: boolean; children: Group[] };
type Group = { title: string | null; entries: Entry[] };
type Title = { label: string; href: string; logo: string | null };
type Props = { document?: string; document_path?: string; current?: string; title?: Title | null; groups?: Group[] };
// The endpoint's answer: the pages found, best first, each with its best
// sections and a count of the rest.
type Section = { heading: string; path: string[]; url: string; snippet: string; words: string[] };
type FoundPage = {
  title: string;
  url: string;
  trail: string[];
  document: { title: string; path: string };
  kind: "page" | "chapter" | "slide";
  title_match: boolean;
  lede: string;
  sections: Section[];
  more: number;
};
// What the server answered, and for which query.
type Found = { query: string; pages: FoundPage[] };
// One row of the results, in the order the arrows move through them.
type Row = { kind: "page"; page: FoundPage } | { kind: "section"; page: FoundPage; section: Section } | { kind: "more"; page: FoundPage };

const storageKey = (document: string) => `devpage-sidebar:${document}`;

// What the reader opened on this document, by address; none when storage is
// blocked or holds something else.
function remembered(document: string): string[] {
  try {
    const opened = JSON.parse(localStorage.getItem(storageKey(document)) ?? "[]");
    return Array.isArray(opened) ? opened : [];
  } catch {
    return [];
  }
}

function remember(document: string, href: string, open: boolean) {
  try {
    const opened = remembered(document).filter((h) => h !== href);
    if (open) opened.push(href);
    localStorage.setItem(storageKey(document), JSON.stringify(opened));
  } catch {
    // Storage may be blocked; the entry stays open for this visit.
  }
}

const isShown = (entry: Entry, current: string) => entry.home && entry.href === current;

const leadsTo = (entry: Entry, current: string): boolean =>
  isShown(entry, current) || entry.children.some((g) => g.entries.some((e) => leadsTo(e, current)));

// The addresses of the entries open when `current` is shown: those it is,
// or is under, that have children.
function branch(groups: Group[], current: string): string[] {
  return groups.flatMap((g) => g.entries)
    .filter((e) => e.children.length && leadsTo(e, current))
    .flatMap((e) => [e.href, ...branch(e.children, current)]);
}

function Tree({ groups, current, open, toggle, hidden = false }: { groups: Group[]; current: string; open: Set<string>; toggle: (href: string) => void; hidden?: boolean }) {
  if (!groups.length) return null;
  return (
    <ul hidden={hidden}>
      {groups.flatMap((group, g) => [
        group.title !== null ? <li key={`group ${g}`} className="group"><span>{group.title}</span></li> : null,
        ...group.entries.map((entry, i) => {
          const link = <a href={entry.href} className={isShown(entry, current) ? "current" : undefined}>{entry.label}</a>;
          if (!entry.children.length) return <li key={`${g} ${i}`}>{link}</li>;
          const isOpen = open.has(entry.href);
          return (
            <li key={`${g} ${i}`} className={isOpen ? "branch open" : "branch"} data-href={entry.href}>
              <div className="entry">
                {link}
                <button type="button" className="chevron" aria-expanded={isOpen} aria-label={entry.label} onClick={() => toggle(entry.href)} />
              </div>
              <Tree groups={entry.children} current={current} open={open} toggle={toggle} />
            </li>
          );
        }),
      ])}
    </ul>
  );
}

// How long typing pauses before the box asks: short enough that hits come
// while typing, long enough that a word typed fast is one request.
const DEBOUNCE_MS = 60;

// Each page's rows: its own, its sections', then the rest's.
const rowsOf = (page: FoundPage): Row[] => [
  { kind: "page", page },
  ...page.sections.map((section): Row => ({ kind: "section", page, section })),
  ...(page.more ? [{ kind: "more", page } as Row] : []),
];

// The row selected when results come: the best page's row when it was found
// by its title, else its best section.
const firstPick = (pages: FoundPage[]) => (pages[0] && !pages[0].title_match && pages[0].sections.length ? 1 : 0);

// Whether a key pressed there is typing: `/` types there, not searches.
const typing = (target: EventTarget | null) =>
  target instanceof Element && (target.closest("input, textarea, select") !== null || (target as HTMLElement).isContentEditable);

// The search box and, while it holds text, its hits. Lives as long as the
// sidebar, so its text and hits persist across page swaps.
function useSearch() {
  const [query, setQuery] = useState("");
  const [found, setFound] = useState<Found>({ query: "", pages: [] });
  const [selected, setSelected] = useState(0);
  const input = useRef<HTMLInputElement>(null);
  const rowLinks = useRef<(HTMLAnchorElement | null)[]>([]);
  // The latest request; an answer to any other is stale and dropped.
  const asked = useRef(0);
  // Enter pressed before the hits for the text typed came: open the first.
  const openOnArrival = useRef(false);

  useEffect(() => {
    const generation = ++asked.current;
    const q = query.trim();
    if (!q) return;
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      try {
        const response = await fetch(`/__devpage/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
        const { pages = [] } = await response.json();
        if (generation !== asked.current) return;
        setFound({ query, pages });
        setSelected(firstPick(pages));
      } catch {
        // Aborted by newer text, or the server is gone: the results shown stay.
      }
    }, DEBOUNCE_MS);
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  // While the box holds text, the last results answered take the tree's
  // place — none yet, before the first answer — so the tree never flashes
  // back between keystrokes. They are `fresh` once they answer the text typed.
  const pages = query.trim() ? found.pages : null;
  const rows = pages?.flatMap(rowsOf) ?? [];
  const fresh = found.query === query;

  // Once the results for the text typed are in, a pending Enter opens the
  // row selected first.
  useEffect(() => {
    if (!openOnArrival.current || !fresh) return;
    openOnArrival.current = false;
    rowLinks.current[selected]?.click();
  });

  // The selected row stays in view as the arrows move it.
  useEffect(() => {
    rowLinks.current[selected]?.scrollIntoView({ block: "nearest" });
  }, [selected]);

  // `/` anywhere but in a field, or ⌘K / Ctrl+K anywhere, focuses the box.
  useEffect(() => {
    const focus = (event: KeyboardEvent) => {
      const commandK = event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
      const slash = event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey && !typing(event.target);
      if (!commandK && !slash) return;
      event.preventDefault();
      input.current?.focus();
      input.current?.select();
    };
    document.addEventListener("keydown", focus);
    return () => document.removeEventListener("keydown", focus);
  }, []);

  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    // An input method composing a word owns its keys.
    if (event.nativeEvent.isComposing) return;
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      if (!rows.length) return;
      event.preventDefault();
      const step = event.key === "ArrowDown" ? 1 : -1;
      setSelected((at) => (at + step + rows.length) % rows.length);
    } else if (event.key === "Enter") {
      event.preventDefault();
      if (fresh) rowLinks.current[selected]?.click();
      else if (query.trim()) openOnArrival.current = true;
    } else if (event.key === "Escape") {
      event.preventDefault();
      if (!query) input.current?.blur();
      clear();
    }
  };

  // Empties the box and brings the tree back.
  const clear = () => {
    openOnArrival.current = false;
    setQuery("");
    setFound({ query: "", pages: [] });
  };

  return { query, setQuery, pages, rows, fresh, selected, setSelected, input, rowLinks, onKeyDown, clear };
}

// One row of the results, a link. A page's row: where it is — its
// document, when not this sidebar's, and its place in the nav above it —
// its title, and its lede when it was found by its title; it opens the
// page at its top. A section's row: its heading and the run of its text
// that matched, the words marked by the server; it opens the page at the
// first of them, marked. The more row opens the page.
function RowLink({ row, documentPath, id, selected, link, onPoint }: { row: Row; documentPath: string; id: string; selected: boolean; link: (a: HTMLAnchorElement | null) => void; onPoint: () => void }) {
  const { page } = row;
  const props = {
    ref: link,
    id,
    role: "option",
    "aria-selected": selected,
    className: `hit hit-${row.kind}${selected ? " selected" : ""}`,
    onMouseMove: onPoint,
  };
  if (row.kind === "section") {
    const { section } = row;
    return (
      <a {...props} href={section.url} data-found={JSON.stringify(section.words)} title={section.path.join(" › ")}>
        <span className="hit-label">{section.heading || page.title}</span>
        {section.snippet ? <span className="hit-snippet" dangerouslySetInnerHTML={{ __html: section.snippet }} /> : null}
      </a>
    );
  }
  if (row.kind === "more") {
    return (
      <a {...props} href={page.url} data-found="[]">
        <span className="hit-label">{page.more} more in this page</span>
      </a>
    );
  }
  const place = [...(page.document.path !== documentPath ? [page.document.title] : []), ...page.trail.slice(0, -1)]
    .filter((label, i, all) => label !== all[i - 1] && label !== page.title)
    .join(" › ");
  return (
    <a {...props} href={page.url} data-found="[]">
      {place ? <span className="hit-place">{place}</span> : null}
      <span className="hit-label">{page.title}</span>
      {page.title_match && page.lede ? <span className="hit-lede">{page.lede}</span> : null}
    </a>
  );
}

export default function DevpageSidebar({ document = "", document_path = "", current = "", title = null, groups = [] }: Props) {
  const search = useSearch();
  const resultsId = useId();
  const [open, setOpen] = useState(() => new Set([...remembered(document), ...branch(groups, current)]));
  // A page swapped in opens its branch; what else is open stays open. A page
  // of another site starts from what the reader opened there.
  const [shown, setShown] = useState({ document, current });
  let opened = open;
  if (shown.document !== document || shown.current !== current) {
    opened = new Set([...(shown.document === document ? open : remembered(document)), ...branch(groups, current)]);
    setOpen(opened);
    setShown({ document, current });
  }
  // From the state as it is when React applies the click, so clicks that
  // come before a render each count. Remembering is idempotent.
  const toggle = (href: string) =>
    setOpen((before) => {
      const next = new Set(before);
      const isOpen = !next.has(href);
      if (isOpen) next.add(href);
      else next.delete(href);
      remember(document, href, isOpen);
      return next;
    });
  return (
    <>
      {title ? (
        <a className={title.href === current ? "document-title current" : "document-title"} href={title.href}>
          {title.logo ? <img className="logo" src={title.logo} alt="" /> : null}
          {title.label}
        </a>
      ) : null}
      {/* Before anything is typed this is render::SEARCH_BOX, which the
          server draws: React takes it over unchanged. */}
      <div className="search">
        <input
          ref={search.input}
          type="search"
          placeholder="Search"
          aria-label="Search the documents"
          autoComplete="off"
          spellCheck={false}
          role="combobox"
          aria-expanded={search.pages !== null}
          aria-autocomplete="list"
          aria-controls={search.pages ? resultsId : undefined}
          aria-activedescendant={search.rows.length ? `${resultsId}-${search.selected}` : undefined}
          value={search.query}
          onChange={(event) => search.setQuery(event.target.value)}
          onKeyDown={search.onKeyDown}
        />
        <kbd>/</kbd>
        {search.query ? (
          // Pressing it keeps the focus in the box, so typing goes on there.
          <button type="button" className="clear" aria-label="Clear search" onMouseDown={(event) => event.preventDefault()} onClick={() => { search.clear(); search.input.current?.focus(); }} />
        ) : null}
      </div>
      {search.pages ? (
        <div className="search-results" id={resultsId} role="listbox" aria-label="Search results">
          {search.pages.length ? (
            search.pages.map((page) => {
              // The page's own row, then the rows under it, indented.
              const first = search.rows.findIndex((r) => r.page === page);
              const [own, ...under] = rowsOf(page);
              const rowLink = (row: Row, at: number) => (
                <RowLink
                  key={at}
                  row={row}
                  documentPath={document_path}
                  id={`${resultsId}-${at}`}
                  selected={at === search.selected}
                  link={(a) => {
                    search.rowLinks.current[at] = a;
                  }}
                  onPoint={() => search.setSelected(at)}
                />
              );
              return (
                <div key={page.url} className="search-page" role="group" aria-label={page.title}>
                  {rowLink(own, first)}
                  {under.length ? <div className="search-sections">{under.map((row, i) => rowLink(row, first + 1 + i))}</div> : null}
                </div>
              );
            })
          ) : search.fresh ? (
            <p className="search-empty">No results for “{search.query.trim()}”</p>
          ) : null}
        </div>
      ) : null}
      <Tree groups={groups} current={current} open={opened} toggle={toggle} hidden={search.pages !== null} />
    </>
  );
}