~cypheon/idris2-quickdocs

85bddf62ab05d8572dcd0785483619f1654a9b29 — Johann Rudloff 1 year, 5 months ago
Initial commit
6 files changed, 533 insertions(+), 0 deletions(-)

A app.js
A index.html
A mkindex.py
A poetry.lock
A pyproject.toml
A style.css
A  => app.js +269 -0
@@ 1,269 @@
(function() {

let data = [];
let cells = [];
let initialized = false;
let matched = [];
let selected = 0;

let pendingUpdate = null;

const IDX_NAME = 0;
const IDX_NAMESPACE = 1;
const IDX_PACKAGE = 2;
const IDX_TARGET = 3;

const IDX_NAME_LOWER = 4;
const IDX_FULLNAME_LOWER = 5;

function renderEntry(entry, idx) {
  const mkEl = (s) => document.createElement(s);
  const li = mkEl('li');
  li.className = 'indexentry';
  const a = mkEl('a');
  a.setAttribute('href', 'data/' + entry[IDX_TARGET]);
  li.appendChild(a);
  li.setAttribute('data-entryidx', idx);

  const name = mkEl('span');
  name.className = 'name';
  name.innerHTML = entry[IDX_NAME];
  a.appendChild(name);

  const pkg = mkEl('span');
  pkg.className = 'package dimmed';
  pkg.innerHTML = '[' + entry[IDX_PACKAGE] + ']';
  a.appendChild(pkg);

  a.appendChild(mkEl('br'));

  const ns = mkEl('span');
  ns.className = 'namespace dimmed';
  ns.innerHTML = entry[IDX_NAMESPACE];
  a.appendChild(ns);

  a.addEventListener('click', clickResult);

  return li;
}

function isSubsequence(s, query) {
  let pos = 0;
  let queryPos = 0;

  while (queryPos < query.length) {
    const c = query[queryPos];
    const idx = s.indexOf(c, pos);

    if (idx === -1) {
      return false;
    }
    pos = idx + 1;
    ++queryPos;
  }

  return true;
}

function matchSubsequence(item, query) {
  return isSubsequence(item[IDX_FULLNAME_LOWER], query);
}

function matchNamespace(item, query) {
  const quoted = query.replaceAll(/[.+*\\()\[\]<>$^]/g, '\\$&');
  const reQuery = new RegExp(quoted.replaceAll('\\.', '.*\\.'));
  return reQuery.test(item[IDX_FULLNAME_LOWER]);
}

function matchSimple(item, query) {
  if (item[IDX_FULLNAME_LOWER].indexOf(query) != -1) {
    return true;
  }
  return false;
}

function openResult(linkTarget) {
  const iframe = document.querySelector('#content iframe');
  iframe.src = linkTarget;
}

function clickResult(evt) {
  const linkTarget = evt.currentTarget.getAttribute('href');

  openResult(linkTarget);

  const entryIdx = Number(evt.currentTarget.parentElement.getAttribute('data-entryidx'));
  selected = matched.indexOf(entryIdx);

  updateSelected();

  evt.preventDefault();
  return false;
}

const matches = matchNamespace;

function searchKeyDown(evt) {
  if (evt.key === 'ArrowDown') {
    ++selected;
    updateSelected();
    evt.preventDefault();
    return false;
  }
  if (evt.key === 'ArrowUp') {
    --selected;
    updateSelected();
    evt.preventDefault();
    return false;
  }
  if (evt.key === 'Enter') {
    const chosen = matched[selected];
    const chosenCell = cells[chosen];
    chosenCell.firstElementChild.click();
    evt.preventDefault();
    return false;
  }
}

function updateCellVisibility(startIndex) {
  // Keep track of how many cells where "switched" from display "block" <->
  // "none" to limit time for frame update
  let cellsChanged = 0;
  const UPDATE_BATCH_SIZE = 500;
  let i;
  for (i = startIndex; i < cells.length; ++i) {
    const entryMatches = matched.indexOf(i) !== -1;

    if (cellsChanged < UPDATE_BATCH_SIZE) {
      const oldStatus = cells[i].style.display;
      const newStatus = entryMatches ? 'block' : 'none';
      if (oldStatus !== newStatus) {
        cells[i].style.display = newStatus;
        ++cellsChanged;
      }
    } else {
      break;
    }
  }

  if (i < cells.length) {
    pendingUpdate = window.requestAnimationFrame(() => {
      // continue update in next frame
      updateCellVisibility(i);
    });
  }
}

function updateSearchResults(evt) {
  const query = evt.target.value.toLocaleLowerCase('en-US');
  matched = [];

  console.log(`search (${cells.length} / ${data.length}): `, query);
  const startTime = Number(new Date());

  for (let i = 0; i < data.length; ++i) {
    const entryMatches = matches(data[i], query);
    if (entryMatches) {
      matched.push(i);
    }
  }
  const finishTime = Number(new Date());
  console.log(`search took ${finishTime - startTime} ms`);

  if (pendingUpdate !== null) {
    window.cancelAnimationFrame(pendingUpdate);
    pendingUpdate = null;
  }
  updateCellVisibility(0);

  updateSelected();
}

function updateSelected() {
  selected = Math.max(0, Math.min(selected, matched.length - 1));
  let selectedCell = undefined;
  for (let i = 0; i < cells.length; ++i) {
    if (i === matched[selected]) {
      cells[i].classList.add('result-selected');
      selectedCell = cells[i];
    } else {
      cells[i].classList.remove('result-selected');
    }
  }

  if (selectedCell === undefined || !selectedCell.offsetParent) {
    return;
  }

  const parent = selectedCell.offsetParent;
  if (parent.scrollTop > selectedCell.offsetTop) {
    selectedCell.scrollIntoView(true);
  } else if ((parent.scrollTop + parent.offsetHeight) < (selectedCell.offsetTop + selectedCell.offsetHeight)) {
    selectedCell.scrollIntoView(false);
  }
}

function init() {
  if (initialized) {
    // do not init twice
    return;
  }
  initialized = true;

  const search = document.getElementById('i2d_search_results');
  const searchBox = document.getElementById('i2d_searchbox')

  fetch('data/index.json').then((res) => {
    if (res.ok) {
      return res.json();
    } else {
      throw new Error("error loading index: " + e.status);
    }
  }).then((index) => {
    data = index;
    cells = [];
    matched = [];
    data.forEach((entry) => {
      entry[IDX_NAME_LOWER] = entry[IDX_NAME].toLocaleLowerCase('en-US');
      entry[IDX_FULLNAME_LOWER] = entry[IDX_NAMESPACE].toLocaleLowerCase('en-US') + '.' + entry[IDX_NAME].toLocaleLowerCase('en-US');

      const idx = cells.length;

      const newNode = renderEntry(entry, idx);
      search.appendChild(newNode);
      cells.push(newNode);
      matched.push(idx);
    });

    updateSelected();
    searchBox.focus();
  });

  searchBox.addEventListener('input', (evt) => {
    updateSearchResults(evt);
  });

  searchBox.addEventListener('keydown', searchKeyDown);

  document.addEventListener('keydown', (evt) => {
    if (evt.key === 'Tab' && document.activeElement !== searchBox) {
      searchBox.focus();
      searchBox.select();
      evt.preventDefault();
      return false;
    }
  });

  const iframe = document.querySelector('#content iframe');
  iframe.addEventListener('load', (evt) => {
    console.log('iframe loaded', evt);
    document.title = iframe.contentWindow.document.title;
  });
}

document.addEventListener('DOMContentLoaded', init, {once: true});
if (document.readyState === 'interactive') {
  init();
}

})();

A  => index.html +24 -0
@@ 1,24 @@
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Idris2 Docs</title>
    <link rel="stylesheet" href="style.css">
    <script src="app.js" async></script>
  </head>
  <body class="nodebug">
    <div class="flex-container">
    <div id="sidebar">
      <div class="searchbox-container">
        <input type=text class="searchbox" id="i2d_searchbox" placeholder="Type to begin searching">
      </div>
      <ul id="i2d_search_results">
      </ul>
    </div>
    <div id="content">
      <iframe style="width: 100%; height: 100%;" src="data/home.html">
      </iframe>
    </div>
    </div>
  </body>
</html>

A  => mkindex.py +78 -0
@@ 1,78 @@
#!/usr/bin/env python3

import json
from pathlib import Path
import re
import traceback
from typing import NamedTuple

from bs4 import BeautifulSoup

class IndexEntry(NamedTuple):
    name: str
    namespace: str
    package: str
    target: str

RE_NAMESPACE = re.compile(r"""(?P<ns>([^.]+\.)+)(?P<name>.+)""")
def split_ns(full_name):
    match = RE_NAMESPACE.match(full_name)
    return match.group('ns')[:-1], match.group('name')

def sortkey(x):
    return (x.name.lower(),
            x.namespace.lower(),
            x.package.lower(),
            x.target
            )

class IndexBuilder:
    def __init__(self, root: str):
        self.root = Path(root)
        self.entries = []

    def run(self):
        sources = self.root.glob('**/*.html')

        for source in sources:
            try:
                self.scan(source)
            except Exception as e:
                print(f"error processing file {source}")
                traceback.print_exc()

        self.entries.sort(key=sortkey)
        with open(self.root / 'index.json', 'w') as f:
            json.dump(self.entries, f)

    def scan(self, source):
        entries = []
        relpath = source.relative_to(self.root)
        package = relpath.parts[0]
        print(f"scanning {source}")

        with open(source) as f:
            soup = BeautifulSoup(f, 'html.parser')

        docclass = soup.find('body')['class']
        if 'namespace' not in docclass:
            return

        for span in soup.select('dl.decls > dt > span.name'):
            id = span.parent['id']
            full_name = span.get_text(strip=True)
            namespace, name = split_ns(full_name)
            # print(f"{full_name} -> {namespace} , {name}")
            # namespace, name = full_name.rsplit('.', 1)
            entries.append(IndexEntry(
                name,
                namespace,
                package,
                relpath.as_posix() + '#' + id
            ))
        self.entries += entries

if __name__ == '__main__':
    import sys
    builder = IndexBuilder(sys.argv[1])
    builder.run()

A  => poetry.lock +40 -0
@@ 1,40 @@
[[package]]
category = "main"
description = "Screen-scraping library"
name = "beautifulsoup4"
optional = false
python-versions = "*"
version = "4.9.3"

[package.dependencies]
[package.dependencies.soupsieve]
python = ">=3.0"
version = ">1.2"

[package.extras]
html5lib = ["html5lib"]
lxml = ["lxml"]

[[package]]
category = "main"
description = "A modern CSS selector implementation for Beautiful Soup."
marker = "python_version >= \"3.0\""
name = "soupsieve"
optional = false
python-versions = ">=3.5"
version = "2.1"

[metadata]
content-hash = "0aed234b5ffb3d308de24294c6b9d687f1f61ff50711eea9cb63a07a57074644"
python-versions = "^3.8"

[metadata.files]
beautifulsoup4 = [
    {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"},
    {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"},
    {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
]
soupsieve = [
    {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"},
    {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"},
]

A  => pyproject.toml +15 -0
@@ 1,15 @@
[tool.poetry]
name = "idris2-quickdocs"
version = "0.1.0"
description = ""
authors = ["Johann Rudloff <johann@sinyax.net>"]

[tool.poetry.dependencies]
python = "^3.8"
beautifulsoup4 = "^4.9.3"

[tool.poetry.dev-dependencies]

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

A  => style.css +107 -0
@@ 1,107 @@
.debug div {
  border: 1px solid red;
}

#sidebar {
  width: 280px;
  height: 100%;
  display: flex;
  flex-direction: column;
  border-right: 1px solid #ccc;
}

.searchbox-container {
  width: 100%;
  height: 31px;
  flex-shrink: 0;
  background-color: rgb(37, 37, 37);
  /*border-bottom: 3px solid rgb(101, 159, 219);*/
  display: flex;
  flex-direction: row;
}

.searchbox {
  width: 100%;
}

#i2d_search_results {
  position: relative;
  overflow-y: scroll;
  overflow-x: hidden;
  flex-grow: 100;
  margin: 0;
  padding: 0;
}

#content {
  flex-grow: 100;
  overflow: hidden;
}

#content > iframe {
  border: 0;
}

.result-hidden {
  display: none;
}

.flex-container {
  display: flex;
  flex-direction: row;
  height: 100%;
}

.indexentry {
  position: relative;
}

.indexentry .package {
  float: right;
  text-align: right;
  font-size: 85%;
}

.indexentry .name {
  color: #000;
  font-family: Mensch, Menlo, "DejaVu Sans Mono", monospace;
  background-color: inherit;
}

.indexentry a {
  display: block;
  padding: 4px;
  background-color: inherit;

  color: #000;
  text-decoration: none;
}

.indexentry .dimmed {
  color: #999;
}

.result-selected .dimmed {
  color: rgb(50, 80, 109);
}

.indexentry:hover {
  background-color: #eee;
}

.result-selected, .result-selected:hover {
  background-color: rgb(101, 159, 219);
}

html, body {
  margin: 0;
  height: 100%;
  box-sizing: border-box;

  font-family: "Trebuchet MS", Helvetica, sans-serif;
  font-size: 10pt;
}

* {
  box-sizing: inherit;
}