diff --git a/DocGen4/Output.lean b/DocGen4/Output.lean index 4f549c2..7351530 100644 --- a/DocGen4/Output.lean +++ b/DocGen4/Output.lean @@ -10,6 +10,7 @@ import DocGen4.Output.Index import DocGen4.Output.Module import DocGen4.Output.NotFound import DocGen4.Output.Find +import DocGen4.Output.Semantic namespace DocGen4 @@ -65,28 +66,46 @@ def htmlOutput (result : AnalyzerResult) (root : String) : IO Unit := do let config := { root := root, result := result, currentName := none, sourceLinker := ←sourceLinker} let basePath := FilePath.mk "." / "build" / "doc" let indexHtml := ReaderT.run index config + let findHtml := ReaderT.run find config let notFoundHtml := ReaderT.run notFound config FS.createDirAll basePath FS.createDirAll (basePath / "find") + FS.createDirAll (basePath / "semantic") let mut declList := #[] for (_, mod) in result.moduleInfo.toArray do for decl in filterMapDocInfo mod.members do - let findHtml := ReaderT.run (findRedirectHtml decl.getName) config - let findDir := basePath / "find" / decl.getName.toString - FS.createDirAll findDir - FS.writeFile (findDir / "index.html") findHtml.toString - let obj := Json.mkObj [("name", decl.getName.toString), ("description", decl.getDocString.getD "")] + let name := decl.getName.toString + let doc := decl.getDocString.getD "" + let link := root ++ s!"semantic/{decl.getName.hash}.xml#" + let docLink := Id.run <| ReaderT.run (declNameToLink decl.getName) config + let sourceLink := Id.run <| ReaderT.run (getSourceUrl mod.name decl.getDeclarationRange) config + let obj := Json.mkObj [("name", name), ("doc", doc), ("link", link), ("docLink", docLink), ("sourceLink", sourceLink)] declList := declList.push obj + let xml := toString <| Id.run <| ReaderT.run (semanticXml decl) config + FS.writeFile (basePath / "semantic" / s!"{decl.getName.hash}.xml") xml let json := Json.arr declList - FS.writeFile (basePath / "searchable_data.bmp") json.compress + FS.writeFile (basePath / "semantic" / "docgen4.xml") <| toString <| Id.run <| ReaderT.run schemaXml config + FS.writeFile (basePath / "index.html") indexHtml.toString - FS.writeFile (basePath / "style.css") styleCss FS.writeFile (basePath / "404.html") notFoundHtml.toString + FS.writeFile (basePath / "find" / "index.html") findHtml.toString + + FS.writeFile (basePath / "style.css") styleCss + + let declarationDataPath := basePath / "declaration-data.bmp" + FS.writeFile declarationDataPath json.compress + FS.writeFile (basePath / "declaration-data.timestamp") <| toString (←declarationDataPath.metadata).modified.sec + + FS.writeFile (basePath / "site-root.js") (siteRootJs.replace "{siteRoot}" config.root) + FS.writeFile (basePath / "declaration-data.js") declarationDataCenterJs FS.writeFile (basePath / "nav.js") navJs + FS.writeFile (basePath / "find" / "find.js") findJs + FS.writeFile (basePath / "how-about.js") howAboutJs FS.writeFile (basePath / "search.js") searchJs FS.writeFile (basePath / "mathjax-config.js") mathjaxConfigJs + for (module, content) in result.moduleInfo.toArray do let moduleHtml := ReaderT.run (moduleToHtml content) config let path := moduleNameToFile basePath module diff --git a/DocGen4/Output/Base.lean b/DocGen4/Output/Base.lean index 68812a8..2a28b17 100644 --- a/DocGen4/Output/Base.lean +++ b/DocGen4/Output/Base.lean @@ -47,8 +47,12 @@ def moduleNameToDirectory (basePath : FilePath) (n : Name) : FilePath := section Static def styleCss : String := include_str "./static/style.css" + def siteRootJs : String := include_str "./static/site-root.js" + def declarationDataCenterJs : String := include_str "./static/declaration-data.js" def navJs : String := include_str "./static/nav.js" + def howAboutJs : String := include_str "./static/how-about.js" def searchJs : String := include_str "./static/search.js" + def findJs : String := include_str "./static/find/find.js" def mathjaxConfigJs : String := include_str "./static/mathjax-config.js" end Static diff --git a/DocGen4/Output/Find.lean b/DocGen4/Output/Find.lean index 41b82dd..95bbd8e 100644 --- a/DocGen4/Output/Find.lean +++ b/DocGen4/Output/Find.lean @@ -6,10 +6,16 @@ namespace Output open scoped DocGen4.Jsx open Lean -def findRedirectHtml (decl : Name) : HtmlM Html := do - let res ← getResult - let url ← declNameToLink decl - let contentString := s!"0;url={url}" - pure $ Html.element "meta" false #[("http-equiv", "refresh"), ("content", contentString)] #[] +def find : HtmlM Html := do + pure + + + + + + + + end Output end DocGen4 + diff --git a/DocGen4/Output/Semantic.lean b/DocGen4/Output/Semantic.lean new file mode 100644 index 0000000..7b22cdf --- /dev/null +++ b/DocGen4/Output/Semantic.lean @@ -0,0 +1,58 @@ +import DocGen4.Output.Template +import DocGen4.Output.DocString +import Lean.Data.Xml + +open Lean Xml + +namespace DocGen4 +namespace Output + +instance : ToString $ Array Element where + toString xs := xs.map toString |>.foldl String.append "" + +instance : Coe Element Content where + coe e := Content.Element e + +-- TODO: syntax metaprogramming and basic semantic data + +def semanticXml (i : DocInfo) : HtmlM $ Array Element := do + pure #[ + Element.Element + "rdf:RDF" + (Std.RBMap.fromList [ + ("xmlns:rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"), + ("xmlns:docgen4", s!"{←getRoot}semactic/docgen4.xml#") + ] _) + #[ + Element.Element + "rdf:Description" + (Std.RBMap.fromList [ + ("rdf:about", s!"{←getRoot}semactic/{i.getName.hash}.xml#") + ] _) + #[] + ] + ] + +def schemaXml : HtmlM $ Array Element := do + pure #[ + Element.Element + "rdf:RDF" + (Std.RBMap.fromList [ + ("xmlns:rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"), + ("xmlns:docgen4", s!"{←getRoot}semactic/docgen4.xml#") + ] _) + #[ + Element.Element + "docgen4:hasInstance" + Std.RBMap.empty + #[ + Element.Element + "rdfs:type" + Std.RBMap.empty + #[Content.Character "rdf:Property"] + ] + ] + ] + +end Output +end DocGen4 diff --git a/DocGen4/Output/Template.lean b/DocGen4/Output/Template.lean index 8ed58ee..05390fe 100644 --- a/DocGen4/Output/Template.lean +++ b/DocGen4/Output/Template.lean @@ -15,46 +15,48 @@ def baseHtmlArray (title : String) (site : Array Html) : HtmlM Html := do pure + + {title} + + + + - {title} - - + + + + + + + + + + - + -
-

Documentation

-

{title}

- -- TODO: Replace this form with our own search -
- - - -
-
+
+

Documentation

+

{title}

+ -- TODO: Replace this form with our own search +
+ + + +
+
- [site] - - {←navbar} + [site] + + {←navbar} - -- Lean in JS in HTML in Lean...very meta - - - -- TODO Add more js stuff - - - -- mathjax - - - + diff --git a/static/declaration-data.js b/static/declaration-data.js new file mode 100644 index 0000000..cb7a90c --- /dev/null +++ b/static/declaration-data.js @@ -0,0 +1,243 @@ +/** + * This module is a wrapper that facilitates manipulating the declaration data. + * + * Please see {@link DeclarationDataCenter} for more information. + */ + +import { SITE_ROOT } from "./site-root.js"; + +const CACHE_DB_NAME = "declaration-data"; +const CACHE_DB_VERSION = 1; + +/** + * The DeclarationDataCenter is used for declaration searching. + * + * For usage, see the {@link init} and {@link search} methods. + */ +export class DeclarationDataCenter { + /** + * The declaration data. Users should not interact directly with this field. + * + * *NOTE:* This is not made private to support legacy browsers. + */ + declarationData = null; + + /** + * Used to implement the singleton, in case we need to fetch data mutiple times in the same page. + */ + static singleton = null; + + /** + * Construct a DeclarationDataCenter with given data. + * + * Please use {@link DeclarationDataCenter.init} instead, which automates the data fetching process. + * @param {*} declarationData + */ + constructor(declarationData) { + this.declarationData = declarationData; + } + + /** + * The actual constructor of DeclarationDataCenter + * @returns {Promise} + */ + static async init() { + if (!DeclarationDataCenter.singleton) { + const timestampUrl = new URL( + `${SITE_ROOT}declaration-data.timestamp`, + window.location + ); + const dataUrl = new URL( + `${SITE_ROOT}declaration-data.bmp`, + window.location + ); + + const timestampRes = await fetch(timestampUrl); + const timestamp = await timestampRes.text(); + + // try to use cache first + const data = await fetchCachedDeclarationData(timestamp); + if (data) { + // if data is defined, use the cached one. + DeclarationDataCenter.singleton = new DeclarationDataCenter(data); + } else { + // undefined. then fetch the data from the server. + const dataRes = await fetch(dataUrl); + const dataJson = await dataRes.json(); + // the data is a map of name (original case) to declaration data. + const data = new Map( + dataJson.map(({ name, doc, link, docLink, sourceLink }) => [ + name, + { + name, + lowerName: name.toLowerCase(), + lowerDoc: doc.toLowerCase(), + link, + docLink, + sourceLink, + }, + ]) + ); + await cacheDeclarationData(timestamp, data); + DeclarationDataCenter.singleton = new DeclarationDataCenter(data); + } + } + return DeclarationDataCenter.singleton; + } + + /** + * Search for a declaration. + * @returns {Array} + */ + search(pattern, strict = true) { + if (!pattern) { + return []; + } + if (strict) { + let decl = this.declarationData.get(pattern); + return decl ? [decl] : []; + } else { + return getMatches(this.declarationData, pattern); + } + } +} + +function isSeparater(char) { + return char === "." || char === "_"; +} + +// HACK: the fuzzy matching is quite hacky + +function matchCaseSensitive(declName, lowerDeclName, pattern) { + let i = 0, + j = 0, + err = 0, + lastMatch = 0; + while (i < declName.length && j < pattern.length) { + if (pattern[j] === declName[i] || pattern[j] === lowerDeclName[i]) { + err += (isSeparater(pattern[j]) ? 0.125 : 1) * (i - lastMatch); + if (pattern[j] !== declName[i]) err += 0.5; + lastMatch = i + 1; + j++; + } else if (isSeparater(declName[i])) { + err += 0.125 * (i + 1 - lastMatch); + lastMatch = i + 1; + } + i++; + } + err += 0.125 * (declName.length - lastMatch); + if (j === pattern.length) { + return err; + } +} + +function getMatches(declarations, pattern, maxResults = 30) { + const lowerPats = pattern.toLowerCase().split(/\s/g); + const patNoSpaces = pattern.replace(/\s/g, ""); + const results = []; + for (const { + name, + lowerName, + lowerDoc, + link, + docLink, + sourceLink, + } of declarations.values()) { + let err = matchCaseSensitive(name, lowerName, patNoSpaces); + // match all words as substrings of docstring + if ( + err >= 3 && + pattern.length > 3 && + lowerPats.every((l) => lowerDoc.indexOf(l) != -1) + ) { + err = 3; + } + if (err !== undefined) { + results.push({ + name, + err, + lowerName, + lowerDoc, + link, + docLink, + sourceLink, + }); + } + } + return results.sort(({ err: a }, { err: b }) => a - b).slice(0, maxResults); +} + +// TODO: refactor the indexedDB part to be more robust + +/** + * Get the indexedDB database, automatically initialized. + * @returns {Promise} + */ +async function getDeclarationDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(CACHE_DB_NAME, CACHE_DB_VERSION); + + request.onerror = function (event) { + reject( + new Error( + `fail to open indexedDB ${CACHE_DB_NAME} of version ${CACHE_DB_VERSION}` + ) + ); + }; + request.onupgradeneeded = function (event) { + let db = event.target.result; + // We only need to store one object, so no key path or increment is needed. + db.createObjectStore("declaration"); + }; + request.onsuccess = function (event) { + resolve(event.target.result); + }; + }); +} + +/** + * Store data in indexedDB object store. + * @param {string} timestamp + * @param {Map} data + */ +async function cacheDeclarationData(timestamp, data) { + let db = await getDeclarationDatabase(); + let store = db + .transaction("declaration", "readwrite") + .objectStore("declaration"); + return new Promise((resolve, reject) => { + let clearRequest = store.clear(); + let addRequest = store.add(data, timestamp); + + addRequest.onsuccess = function (event) { + resolve(); + }; + addRequest.onerror = function (event) { + reject(new Error(`fail to store declaration data`)); + }; + clearRequest.onerror = function (event) { + reject(new Error("fail to clear object store")); + }; + }); +} + +/** + * Retrieve data from indexedDB database. + * @param {string} timestamp + * @returns {Promise|undefined>} + */ +async function fetchCachedDeclarationData(timestamp) { + let db = await getDeclarationDatabase(); + let store = db + .transaction("declaration", "readonly") + .objectStore("declaration"); + return new Promise((resolve, reject) => { + let transactionRequest = store.get(timestamp); + transactionRequest.onsuccess = function (event) { + resolve(event.result); + }; + transactionRequest.onerror = function (event) { + reject(new Error(`fail to store declaration data`)); + }; + }); +} diff --git a/static/find/find.js b/static/find/find.js new file mode 100644 index 0000000..4b65f26 --- /dev/null +++ b/static/find/find.js @@ -0,0 +1,75 @@ +/** + * This module is used for the `/find` endpoint. + * + * Two basic kinds of search syntax are supported: + * + * 1. Query-Fragment syntax: `/find?pattern=Nat.add#doc` for documentation and `/find?pattern=Nat.add#src` for source code. + * 2. Fragment-Only syntax:`/find/#doc/Nat.add` for documentation and `/find/#src/Nat.add` for source code. + * + * Though both of them are valid, the first one is highly recommended, and the second one is for compatibility and taste. + * + * There are some extended usage for the QF syntax. For example, appending `strict=false` to the query part + * (`/find?pattern=Nat.add&strict=false#doc`) will allow you to use the fuzzy search, rather than strict match. + * Also, the fragment is extensible as well. For now only `#doc` and `#src` are implement, and the plain query without + * hash (`/find?pattern=Nat.add`) is used for computer-friendly data (semantic web is great! :P), while all other fragments + * fallback to the `#doc` view. + */ + +import { SITE_ROOT } from "../site-root.js"; +import { DeclarationDataCenter } from "../declaration-data.js"; + +/** + * We don't use browser's default hash and searchParams in case Lean declaration name + * can be like `«#»`, rather we manually handle the `window.location.href` with regex. + */ +const LEAN_FRIENDLY_URL_REGEX = /^[^?#]+(?:\?((?:[^«#»]|«.*»)*))?(?:#(.*))?$/; +const LEAN_FRIENDLY_AND_SEPARATOR = /(? p.split(LEAN_FRIENDLY_EQUAL_SEPARATOR)) + ?.filter((l) => l.length == 2 && l[0].length > 0) +); +const fragmentPaths = fragment?.split(LEAN_FRIENDLY_SLASH_SEPARATOR) ?? []; + +const pattern = queryParams.get("pattern") ?? fragmentPaths[1]; // if first fail then second, may be undefined +const strict = (queryParams.get("strict") ?? "true") === "true"; // default to true +const view = fragmentPaths[0]; + +findAndRedirect(pattern, strict, view); + +/** + * Find the result and redirect to the result page. + * @param {string} pattern the pattern to search for + * @param {string} view the view of the find result (`"doc"` or `"src"` for now) + */ +async function findAndRedirect(pattern, strict, view) { + // if no pattern provided, directly redirect to the 404 page + if (!pattern) { + window.location.replace(`${SITE_ROOT}404.html`); + } + // search for result + const dataCenter = await DeclarationDataCenter.init(); + let result = (dataCenter.search(pattern, strict) ?? [])[0]; // in case return non array + // if no result found, redirect to the 404 page + if (!result) { + // TODO: better url semantic for 404, current implementation will lead to duplicate search for fuzzy match if not found. + window.location.replace(`${SITE_ROOT}404.html#${pattern ?? ""}`); + } else { + // success, redirect to doc or source page, or to the semantic rdf. + if (!view) { + window.location.replace(result.link); + } else if (view == "doc") { + window.location.replace(result.docLink); + } else if (view == "src") { + window.location.replace(result.sourceLink); + } else { + // fallback to doc page + window.location.replace(result.docLink); + } + } +} \ No newline at end of file diff --git a/static/how-about.js b/static/how-about.js new file mode 100644 index 0000000..5c03ffd --- /dev/null +++ b/static/how-about.js @@ -0,0 +1,39 @@ +/** + * This module implements the `howabout` functionality in the 404 page. + */ + +import { DeclarationDataCenter } from "./declaration-data.js"; + +const HOW_ABOUT = document.querySelector("#howabout"); + +// Show url of the missing page +if (HOW_ABOUT) { + HOW_ABOUT.parentNode + .insertBefore(document.createElement("pre"), HOW_ABOUT) + .appendChild(document.createElement("code")).innerText = + window.location.href.replace(/[/]/g, "/\u200b"); + + // TODO: add how about functionality for similar page as well. + const pattern = window.location.hash.replace("#", ""); + + // try to search for similar declarations + if (pattern) { + HOW_ABOUT.innerText = "Please wait a second. I'll try to help you."; + DeclarationDataCenter.init().then((dataCenter) => { + let results = dataCenter.search(pattern, false); + if (results.length > 0) { + HOW_ABOUT.innerText = "How about one of these instead:"; + const ul = HOW_ABOUT.appendChild(document.createElement("ul")); + for (const { name, docLink } of results) { + const li = ul.appendChild(document.createElement("li")); + const a = li.appendChild(document.createElement("a")); + a.href = docLink; + a.appendChild(document.createElement("code")).innerText = name; + } + } else { + HOW_ABOUT.innerText = + "Sorry, I cannot find any similar declarations. Check the link or use the module navigation to find what you want :P"; + } + }); + } +} diff --git a/static/mathjax-config.js b/static/mathjax-config.js index 51adbd6..076dbf6 100644 --- a/static/mathjax-config.js +++ b/static/mathjax-config.js @@ -1,17 +1,41 @@ +/* + * This file is for configing MathJax behavior. + * See https://docs.mathjax.org/en/latest/web/configuration.html + * + * This configuration is copied from old doc-gen3 + * https://github.com/leanprover-community/doc-gen + */ MathJax = { - tex: { - inlineMath: [['$', '$']], - displayMath: [['$$', '$$']] - }, - options: { - skipHtmlTags: [ - 'script', 'noscript', 'style', 'textarea', 'pre', - 'code', 'annotation', 'annotation-xml', - 'decl', 'decl_meta', 'attributes', 'decl_args', 'decl_header', 'decl_name', - 'decl_type', 'equation', 'equations', 'structure_field', 'structure_fields', - 'constructor', 'constructors', 'instances' - ], - ignoreHtmlClass: 'tex2jax_ignore', - processHtmlClass: 'tex2jax_process', - }, -}; \ No newline at end of file + tex: { + inlineMath: [["$", "$"]], + displayMath: [["$$", "$$"]], + }, + options: { + skipHtmlTags: [ + "script", + "noscript", + "style", + "textarea", + "pre", + "code", + "annotation", + "annotation-xml", + "decl", + "decl_meta", + "attributes", + "decl_args", + "decl_header", + "decl_name", + "decl_type", + "equation", + "equations", + "structure_field", + "structure_fields", + "constructor", + "constructors", + "instances", + ], + ignoreHtmlClass: "tex2jax_ignore", + processHtmlClass: "tex2jax_process", + }, +}; diff --git a/static/nav.js b/static/nav.js index 7d5347d..ff8404e 100644 --- a/static/nav.js +++ b/static/nav.js @@ -1,248 +1,43 @@ -// Persistent expansion cookie for the file tree -// --------------------------------------------- +/** + * This module is used to implement persistent navbar expansion. + */ +// The variable to store the expansion information. let expanded = {}; -for (const e of (sessionStorage.getItem('expanded') || '').split(',')) { - if (e !== '') { + +// Load expansion information from sessionStorage. +for (const e of (sessionStorage.getItem("expanded") || "").split(",")) { + if (e !== "") { expanded[e] = true; } } +/** + * Save expansion information to sessionStorage. + */ function saveExpanded() { - sessionStorage.setItem("expanded", - Object.getOwnPropertyNames(expanded).filter((e) => expanded[e]).join(",")); + sessionStorage.setItem( + "expanded", + Object.getOwnPropertyNames(expanded) + .filter((e) => expanded[e]) + .join(",") + ); } -for (const elem of document.getElementsByClassName('nav_sect')) { - const id = elem.getAttribute('data-path'); +// save expansion information when user change the expansion. +for (const elem of document.getElementsByClassName("nav_sect")) { + const id = elem.getAttribute("data-path"); if (!id) continue; if (expanded[id]) { elem.open = true; } - elem.addEventListener('toggle', () => { + elem.addEventListener("toggle", () => { expanded[id] = elem.open; saveExpanded(); }); } -for (const currentFileLink of document.getElementsByClassName('visible')) { - currentFileLink.scrollIntoView({block: 'center'}); -} - - - - - - -// Tactic list tag filter -// ---------------------- - -function filterSelectionClass(tagNames, classname) { - if (tagNames.length == 0) { - for (const elem of document.getElementsByClassName(classname)) { - elem.classList.remove("hide"); - } - } else { - // Add the "show" class (display:block) to the filtered elements, and remove the "show" class from the elements that are not selected - for (const elem of document.getElementsByClassName(classname)) { - elem.classList.add("hide"); - for (const tagName of tagNames) { - if (elem.classList.contains(tagName)) { - elem.classList.remove("hide"); - } - } - } - } - } - - function filterSelection(c) { - filterSelectionClass(c, "tactic"); - filterSelectionClass(c, "taclink"); - } - -var filterBoxes = document.getElementsByClassName("tagfilter"); - -function updateDisplay() { - filterSelection(getSelectValues()); -} - -function getSelectValues() { - var result = []; - - for (const opt of filterBoxes) { - - if (opt.checked) { - result.push(opt.value); - } - } - return result; - } - -function setSelectVal(val) { - for (const opt of filterBoxes) { - opt.checked = val; - } -} - -updateDisplay(); - -for (const opt of filterBoxes) { - opt.addEventListener('change', updateDisplay); -} - -const tse = document.getElementById("tagfilter-selectall") -if (tse != null) { - tse.addEventListener('change', function() { - setSelectVal(this.checked); - updateDisplay(); - }); -} - - - - - - -// Simple declaration search -// ------------------------- - -const declURL = new URL(`${siteRoot}searchable_data.bmp`, window.location); -const getDecls = (() => { - let decls; - return () => { - if (!decls) decls = new Promise((resolve, reject) => { - const req = new XMLHttpRequest(); - req.responseType = 'json'; - req.addEventListener('load', () => resolve(loadDecls(req.response))); - req.addEventListener('error', () => reject()); - req.open('GET', declURL); - req.send(); - }) - return decls; - } -})() - -const declSearch = async (q) => getMatches(await getDecls(), q); - -const srId = 'search_results'; -document.getElementById('search_form') - .appendChild(document.createElement('div')) - .id = srId; - -function handleSearchCursorUpDown(down) { - const sel = document.querySelector(`#${srId} .selected`); - const sr = document.getElementById(srId); - if (sel) { - sel.classList.remove('selected'); - const toSelect = down ? - sel.nextSibling || sr.firstChild: - sel.previousSibling || sr.lastChild; - toSelect && toSelect.classList.add('selected'); - } else { - const toSelect = down ? sr.firstChild : sr.lastChild; - toSelect && toSelect.classList.add('selected'); - } -} - -function handleSearchEnter() { - const sel = document.querySelector(`#${srId} .selected`) - || document.getElementById(srId).firstChild; - sel.click(); -} - -const searchInput = document.querySelector('#search_form input[name=q]'); - -searchInput.addEventListener('keydown', (ev) => { - switch (ev.key) { - case 'Down': - case 'ArrowDown': - ev.preventDefault(); - handleSearchCursorUpDown(true); - break; - case 'Up': - case 'ArrowUp': - ev.preventDefault(); - handleSearchCursorUpDown(false); - break; - case 'Enter': - ev.preventDefault(); - handleSearchEnter(); - break; - } -}); - -searchInput.addEventListener('input', async (ev) => { - const text = ev.target.value; - - if (!text) { - const sr = document.getElementById(srId); - sr.removeAttribute('state'); - sr.replaceWith(sr.cloneNode(false)); - return; - } - - document.getElementById(srId).setAttribute('state', 'loading'); - - const result = await declSearch(text); - if (ev.target.value != text) return; - - const oldSR = document.getElementById('search_results'); - const sr = oldSR.cloneNode(false); - for (const {decl} of result) { - const d = sr.appendChild(document.createElement('a')); - d.innerText = decl; - d.title = decl; - d.href = `${siteRoot}find/${decl}`; - } - sr.setAttribute('state', 'done'); - oldSR.replaceWith(sr); -}); - - - - - - -// 404 page goodies -// ---------------- - -const howabout = document.getElementById('howabout'); -if (howabout) { - howabout.innerText = "Please wait a second. I'll try to help you."; - - howabout.parentNode - .insertBefore(document.createElement('pre'), howabout) - .appendChild(document.createElement('code')) - .innerText = window.location.href.replace(/[/]/g, '/\u200b'); - - const query = window.location.href.match(/[/]([^/]+)(?:\.html|[/])?$/)[1]; - declSearch(query).then((results) => { - howabout.innerText = 'How about one of these instead:'; - const ul = howabout.appendChild(document.createElement('ul')); - for (const {decl} of results) { - const li = ul.appendChild(document.createElement('li')); - const a = li.appendChild(document.createElement('a')); - a.href = `${siteRoot}find/${decl}`; - a.appendChild(document.createElement('code')).innerText = decl; - } - }); -} - - - - - - -// Rewrite GitHub links -// -------------------- - -for (const elem of document.getElementsByClassName('gh_link')) { - const a = elem.firstElementChild; - // commit is set in add_commit.js - for (const [prefix, replacement] of commit) { - if (a.href.startsWith(prefix)) { - a.href = a.href.replace(prefix, replacement); - break; - } - } -} +// Scroll to center. +for (const currentFileLink of document.getElementsByClassName("visible")) { + currentFileLink.scrollIntoView({ block: "center" }); +} \ No newline at end of file diff --git a/static/search.js b/static/search.js index 836ef0e..1df8690 100644 --- a/static/search.js +++ b/static/search.js @@ -1,51 +1,104 @@ -function isSep(c) { - return c === '.' || c === '_'; +/** + * This module is used to handle user's interaction with the search form. + */ + +import { DeclarationDataCenter } from "./declaration-data.js"; + + +const SEARCH_FORM = document.querySelector("#search_form"); +const SEARCH_INPUT = SEARCH_FORM.querySelector("input[name=q]"); + +// Create an `div#search_results` to hold all search results. +let sr = document.createElement("div"); +sr.id = "search_results"; +SEARCH_FORM.appendChild(sr); + +/** + * Attach `selected` class to the the selected search result. + */ +function handleSearchCursorUpDown(down) { + const sel = sr.querySelector(`.selected`); + if (sel) { + sel.classList.remove("selected"); + const toSelect = down + ? sel.nextSibling || sr.firstChild + : sel.previousSibling || sr.lastChild; + toSelect && toSelect.classList.add("selected"); + } else { + const toSelect = down ? sr.firstChild : sr.lastChild; + toSelect && toSelect.classList.add("selected"); + } } -function matchCaseSensitive(declName, lowerDeclName, pat) { - let i = 0, j = 0, err = 0, lastMatch = 0 - while (i < declName.length && j < pat.length) { - if (pat[j] === declName[i] || pat[j] === lowerDeclName[i]) { - err += (isSep(pat[j]) ? 0.125 : 1) * (i - lastMatch); - if (pat[j] !== declName[i]) err += 0.5; - lastMatch = i + 1; - j++; - } else if (isSep(declName[i])) { - err += 0.125 * (i + 1 - lastMatch); - lastMatch = i + 1; - } - i++; - } - err += 0.125 * (declName.length - lastMatch); - if (j === pat.length) { - return err; - } +/** + * Perform search (when enter is pressed). + */ +function handleSearchEnter() { + const sel = + sr.querySelector(`.selected`) || + sr.firstChild; + sel.click(); } -function loadDecls(searchableDataCnt) { - return searchableDataCnt.map(({name, description}) => [name, name.toLowerCase(), description.toLowerCase()]) +/** + * Allow user to navigate search results with up/down arrow keys, and choose with enter. + */ +SEARCH_INPUT.addEventListener("keydown", (ev) => { + switch (ev.key) { + case "Down": + case "ArrowDown": + ev.preventDefault(); + handleSearchCursorUpDown(true); + break; + case "Up": + case "ArrowUp": + ev.preventDefault(); + handleSearchCursorUpDown(false); + break; + case "Enter": + ev.preventDefault(); + handleSearchEnter(); + break; + } +}); + +/** + * Remove all children of a DOM node. + */ +function removeAllChildren(node) { + while (node.firstChild) { + node.removeChild(node.lastChild); + } } -function getMatches(decls, pat, maxResults = 30) { - const lowerPats = pat.toLowerCase().split(/\s/g); - const patNoSpaces = pat.replace(/\s/g, ''); - const results = []; - for (const [decl, lowerDecl, lowerDoc] of decls) { - let err = matchCaseSensitive(decl, lowerDecl, patNoSpaces); +/** + * Search autocompletion. + */ +SEARCH_INPUT.addEventListener("input", async (ev) => { + const text = ev.target.value; - // match all words as substrings of docstring - if (!(err < 3) && pat.length > 3 && lowerPats.every(l => lowerDoc.indexOf(l) != -1)) { - err = 3; - } + // If no input clear all. + if (!text) { + sr.removeAttribute("state"); + removeAllChildren(sr); + return; + } - if (err !== undefined) { - results.push({decl, err}); - } - } - return results.sort(({err: a}, {err: b}) => a - b).slice(0, maxResults); -} + // searching + sr.setAttribute("state", "loading"); + const dataCenter = await DeclarationDataCenter.init(); + const result = dataCenter.search(text, false); -if (typeof process === 'object') { // NodeJS - const data = loadDecls(JSON.parse(require('fs').readFileSync('searchable_data.bmp').toString())); - console.log(getMatches(data, process.argv[2] || 'ltltle')); -} \ No newline at end of file + // in case user has updated the input. + if (ev.target.value != text) return; + + // update search results + removeAllChildren(sr); + for (const { name, docLink } of result) { + const d = sr.appendChild(document.createElement("a")); + d.innerText = name; + d.title = name; + d.href = docLink; + } + sr.setAttribute("state", "done"); +}); diff --git a/static/site-root.js b/static/site-root.js new file mode 100644 index 0000000..3dd39a3 --- /dev/null +++ b/static/site-root.js @@ -0,0 +1,4 @@ +/** + * Get the site root, {siteRoot} is to be replaced by doc-gen4. + */ +export const SITE_ROOT = "{siteRoot}"; \ No newline at end of file diff --git a/static/tactic.js b/static/tactic.js new file mode 100644 index 0000000..e40c4ec --- /dev/null +++ b/static/tactic.js @@ -0,0 +1,61 @@ +// TODO: The tactic part seem to be unimplemented now. + +function filterSelectionClass(tagNames, classname) { + if (tagNames.length == 0) { + for (const elem of document.getElementsByClassName(classname)) { + elem.classList.remove("hide"); + } + } else { + // Add the "show" class (display:block) to the filtered elements, and remove the "show" class from the elements that are not selected + for (const elem of document.getElementsByClassName(classname)) { + elem.classList.add("hide"); + for (const tagName of tagNames) { + if (elem.classList.contains(tagName)) { + elem.classList.remove("hide"); + } + } + } + } +} + +function filterSelection(c) { + filterSelectionClass(c, "tactic"); + filterSelectionClass(c, "taclink"); +} + +var filterBoxes = document.getElementsByClassName("tagfilter"); + +function updateDisplay() { + filterSelection(getSelectValues()); +} + +function getSelectValues() { + var result = []; + + for (const opt of filterBoxes) { + if (opt.checked) { + result.push(opt.value); + } + } + return result; +} + +function setSelectVal(val) { + for (const opt of filterBoxes) { + opt.checked = val; + } +} + +updateDisplay(); + +for (const opt of filterBoxes) { + opt.addEventListener("change", updateDisplay); +} + +const tse = document.getElementById("tagfilter-selectall"); +if (tse != null) { + tse.addEventListener("change", function () { + setSelectVal(this.checked); + updateDisplay(); + }); +}