v0.2.0 · 1e731ad

The file tree

The rail on the left of every page: the repository’s folders and files, as an editor’s explorer shows them, folders first. It’s built from the walk’s file list alone, with no file read, so it costs nothing per page beyond its HTML. Each folder is a <details>, open when the current page is inside it, so the tree works without script; the page’s script remembers which folders a reader opened, and where the rail was scrolled, across pages (render.ts).

import { escapeHtml } from "./highlight.js";

interface Dir {
  dirs: Map<string, Dir>;
  files: string[];
}

function index(files: string[]): Dir {
  const top: Dir = { dirs: new Map(), files: [] };
  for (const path of files) {
    const segments = path.split("/");
    let dir = top;
    for (const segment of segments.slice(0, -1)) {
      let next = dir.dirs.get(segment);
      if (!next) {
        next = { dirs: new Map(), files: [] };
        dir.dirs.set(segment, next);
      }
      dir = next;
    }
    dir.files.push(segments.at(-1)!);
  }
  return top;
}

function href(path: string, folder: boolean): string {
  return `/${path.split("/").map(encodeURIComponent).join("/")}${folder ? "/" : ""}`;
}

#Each row carries its depth, and indents itself by it, rather than nesting padding in the lists: so a row’s highlight spans the rail’s full width, as an editor’s explorer does, while its chevron and name sit at their depth.

function renderDir(dir: Dir, prefix: string, current: string, depth: number): string {
  const rows: string[] = [];
  for (const name of [...dir.dirs.keys()].sort()) {
    const path = prefix ? `${prefix}/${name}` : name;
    const here = current === `${path}/`;
    const open = current.startsWith(`${path}/`);
    rows.push(
      `<li><details data-folder="${escapeHtml(path)}"${open ? " open" : ""}><summary style="--depth: ${depth}"${
        here ? ` aria-current="page"` : ""
      }><a href="${escapeHtml(href(path, true))}">${escapeHtml(name)}</a></summary><ul>${renderDir(dir.dirs.get(name)!, path, current, depth + 1)}</ul></details></li>`,
    );
  }
  for (const name of [...dir.files].sort()) {
    if (name === "README.md") continue;
    const path = prefix ? `${prefix}/${name}` : name;
    rows.push(
      `<li><a class="file" style="--depth: ${depth}" href="${escapeHtml(href(path, false))}"${
        current === path ? ` aria-current="page"` : ""
      }>${escapeHtml(name)}</a></li>`,
    );
  }
  return rows.join("");
}

/** The rail for a page at `current` (`""`, `src/`, or `src/store.ts`). A folder's `README.md` isn't
 *  listed: it's that folder's own page. */
export function renderRail(files: string[], current: string, project: string): string {
  return `<nav class="rail" aria-label="Files"><a class="rail-project" href="/"${
    current === "" ? ` aria-current="page"` : ""
  }>${escapeHtml(project)}</a><ul>${renderDir(index(files), "", current, 0)}</ul></nav>`;
}