From a817c56b66d880a0368e9477af6b9a40ba779c86 Mon Sep 17 00:00:00 2001 From: Ajani Bilby <11359344+AjaniBilby@users.noreply.github.com> Date: Sun, 14 Apr 2024 17:12:45 +1000 Subject: [PATCH] +search function --- builder/index.ts | 6 +- builder/page.ts | 34 +++++++++-- builder/search.ts | 10 ++++ builder/toolbar.ts | 2 +- client/index.ts | 14 +++-- client/search.ts | 82 ++++++++++++++++++++++++++ package-lock.json | 56 ++++++++++++++---- package.json | 4 ++ public/main.css | 140 ++++++++++++++++++++++++++++++++++++++++++--- 9 files changed, 316 insertions(+), 32 deletions(-) create mode 100644 builder/search.ts create mode 100644 client/search.ts diff --git a/builder/index.ts b/builder/index.ts index 797d719..5500dfb 100644 --- a/builder/index.ts +++ b/builder/index.ts @@ -1,6 +1,7 @@ import { CreateFolderPage, CreatePage } from "./page"; -import { readdir, stat, mkdir } from "fs/promises"; +import { readdir, stat, mkdir, writeFile } from "fs/promises"; import { CreateToolbar } from "./toolbar"; +import * as Search from "./search"; console.info("Building Docs..."); @@ -30,6 +31,9 @@ async function BuildDir(path: string) { await CreateFolderPage(toolbar, path); await Promise.all(folders.map(BuildDir)); + + + writeFile("./public/search.json", Search.Jsonify()); } diff --git a/builder/page.ts b/builder/page.ts index f035037..5e44bc9 100644 --- a/builder/page.ts +++ b/builder/page.ts @@ -1,27 +1,50 @@ import { readFile, writeFile } from "fs/promises"; import { Path2Name, Reroute } from "./helper"; +import { AddIndex } from "./search"; + + +const search = ``; export async function CreatePage(toolbar: string, path: string) { const extIndex = path.lastIndexOf("."); const ext = path.slice(extIndex); if (ext != ".md") return; + const name = Path2Name(path); + const href = Reroute(path); + const data = await readFile(path, "utf8"); const { html, type } = RenderPage(path, data); + AddIndex({ href, name, text: data }); + const document = ` - ${Path2Name(path)} + ${name} ${toolbar}
-
` - + html - +`
+ ${search} +
+
` + + html + +`
+
`; @@ -42,7 +65,7 @@ export async function CreateFolderPage(toolbar: string, path: string) { ${toolbar} -
+
${search}
`; @@ -57,6 +80,7 @@ function RenderPage(path: string, data: string) { const html = `
` + `${pathFrag.slice(2, -1).join("/")}` + + `🔗` + `
Close
` + `
` + `
` diff --git a/builder/search.ts b/builder/search.ts new file mode 100644 index 0000000..6685f1c --- /dev/null +++ b/builder/search.ts @@ -0,0 +1,10 @@ +export type SearchItem = { href: string, name: string, text: string }; +const index = new Array(); + +export function AddIndex(item: SearchItem) { + index.push(item); +} + +export function Jsonify() { + return JSON.stringify(index); +} \ No newline at end of file diff --git a/builder/toolbar.ts b/builder/toolbar.ts index 20de06d..92a515e 100644 --- a/builder/toolbar.ts +++ b/builder/toolbar.ts @@ -13,7 +13,7 @@ export function CreateToolbar(href: string, folders: string[], files: string[]) } ); - return `
+ return `
${href !== "./docs" ? ` Index` : ""} ${parents.map(x => ` diff --git a/client/index.ts b/client/index.ts index 85b29ac..39d0142 100644 --- a/client/index.ts +++ b/client/index.ts @@ -1,4 +1,5 @@ import { TransitionStart } from "./helper"; +import * as Search from "./search"; const parser = new DOMParser(); @@ -28,8 +29,8 @@ function AnyClick(ev: MouseEvent) { } async function OpenEntry(href: string, caller?: HTMLElement) { - const dashboard = document.querySelector(".dashboard"); - if (!dashboard) throw new Error("Missing dashboard element"); + const stash = document.querySelector(".stash"); + if (!stash) throw new Error("Missing stash element"); const existing = FindOpenEntry(href); @@ -45,8 +46,8 @@ async function OpenEntry(href: string, caller?: HTMLElement) { await TransitionStart(); if (existing) existing.remove(); if (caller) caller.style.removeProperty('view-transition-name'); - dashboard.insertBefore(entry, dashboard.firstChild); - dashboard.scrollTo({top: 0}); + stash.insertBefore(entry, stash.firstChild); + stash.scrollTo({top: 0}); const title = doc.querySelector("title")?.innerText || document.title; history.replaceState({}, title, href); @@ -66,7 +67,7 @@ function FindOpenEntry(href: string) { async function OpenFolder(href: string) { const current = document.querySelector(".toolbar"); - if (!current) throw new Error("Missing dashboard element"); + if (!current) throw new Error("Missing stash element"); const req = await fetch(href); if (!req.ok) throw new Error(`Failed to load ${href}`); @@ -99,6 +100,7 @@ function Save() { async function Expander(ev: MouseEvent) { if (!(ev.target instanceof HTMLElement)) return; + if (ev.target instanceof HTMLAnchorElement) return; const elm = ev.target.closest(".entry"); if (!elm) return; @@ -136,6 +138,8 @@ async function Startup() { for (const page of pages) { await OpenEntry(page); } + + Search.Bind(); } window.addEventListener("load", Startup); \ No newline at end of file diff --git a/client/search.ts b/client/search.ts new file mode 100644 index 0000000..62cc170 --- /dev/null +++ b/client/search.ts @@ -0,0 +1,82 @@ +import lunr from "lunr"; + +function Focus(ev: FocusEvent) { + if (!(ev.target instanceof HTMLInputElement)) return; + ev.target.value = ""; +} + +let searchElm: HTMLInputElement; +let loading = false; +let timer: NodeJS.Timeout; +let index: lunr.Index; +const naming = new Map(); + +async function PreloadIndex() { + if (loading) return; + loading = true; + + const req = await fetch("/search.json"); + if (!req.ok) throw new Error(req.statusText); + + const json = await req.json(); + console.log("Loaded search index", json); + + index = lunr(function () { + this.ref('name'); + this.ref('href'); + this.field('name'); + this.field('text'); + + for (const item of json) { + naming.set(item.href, item.name); + this.add(item); + } + }); +} + +function Keypress(ev: KeyboardEvent) { + if (!(ev.target instanceof HTMLInputElement)) return; + + PreloadIndex(); + + if (timer) clearTimeout(timer); + timer = setTimeout(Search, 200); +} + +function Search() { + if (!index) { + timer = setTimeout(Search, 400); + return; + }; + + const res = index.search(searchElm.value+"*"); + console.log(res); + + const results = document.querySelector("#search .results"); + if (!results) return; + results.innerHTML = ""; + + for (const opt of res) { + const elm = document.createElement("a"); + elm.className = "result"; + elm.innerText = naming.get(opt.ref) || "Unknown"; + elm.href = opt.ref; + elm.setAttribute("entry", "true"); + + const ctx = document.createElement("span"); + ctx.innerText = opt.ref.split("/").slice(0, -1).join("/"); + ctx.className = "comment"; + elm.appendChild(ctx); + + results.appendChild(elm); + } +} + +export function Bind() { + const elm = document.getElementById("search-input"); + if (!(elm instanceof HTMLInputElement)) throw new Error("Missing search box"); + searchElm = elm; + + searchElm.addEventListener("keyup", Keypress); + searchElm.addEventListener("focus", Focus); +} diff --git a/package-lock.json b/package-lock.json index 5000463..cacab96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,20 +6,23 @@ "": { "name": "calculator", "dependencies": { - "ts-node": "^10.9.2" + "@types/lunr": "^2.3.7", + "lunr": "^2.3.9" }, "devDependencies": { "@types/node": "^20.12.7", "esbuild": "^0.20.1", "express": "^4.18.3", "nodemon": "^3.1.0", - "npm-run-all": "^4.1.5" + "npm-run-all": "^4.1.5", + "ts-node": "^10.9.2" } }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -47,6 +50,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -54,12 +58,14 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -68,27 +74,37 @@ "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==" + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==" + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/lunr": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz", + "integrity": "sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==" }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -116,6 +132,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -127,6 +144,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -159,7 +177,8 @@ "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", @@ -410,7 +429,8 @@ "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true }, "node_modules/cross-spawn": { "version": "6.0.5", @@ -494,6 +514,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, "engines": { "node": ">=0.3.1" } @@ -1348,10 +1369,16 @@ "node": ">=10" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true }, "node_modules/media-typer": { "version": "0.3.0", @@ -2244,6 +2271,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -2372,6 +2400,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, "peer": true, "bin": { "tsc": "bin/tsc", @@ -2405,7 +2434,8 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true }, "node_modules/unpipe": { "version": "1.0.0", @@ -2428,7 +2458,8 @@ "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true }, "node_modules/validate-npm-package-license": { "version": "3.0.4", @@ -2506,6 +2537,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, "engines": { "node": ">=6" } diff --git a/package.json b/package.json index 34610fa..b31cf8e 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,15 @@ }, "engines": {}, "devDependencies": { + "@types/lunr": "^2.3.7", "@types/node": "^20.12.7", "esbuild": "^0.20.1", "express": "^4.18.3", "nodemon": "^3.1.0", "npm-run-all": "^4.1.5", "ts-node": "^10.9.2" + }, + "dependencies": { + "lunr": "^2.3.9" } } diff --git a/public/main.css b/public/main.css index b533c74..c74b096 100644 --- a/public/main.css +++ b/public/main.css @@ -6,7 +6,8 @@ body { display: grid; grid-template-columns: auto 1fr; justify-items: center; - gap: 20px; + + min-height: 100vh; background-color: #272822; font-family: Fira Code; @@ -20,8 +21,11 @@ a, a:visited { .toolbar { display: flex; flex-direction: column; + min-width: 170px; + + background-color: #1e1f1c; - padding: 10px 0 10px 10px; + padding: 10px 20px 10px 10px; } .toolbar a { @@ -54,10 +58,18 @@ a[folder][parent] + a[folder]:not([parent]) { margin-top: 5px; } + .dashboard { - max-width: 800px; - margin: 10px 0; + display: flex; + flex-direction: column; + align-items: center; width: 100%; +} + +.stash { + max-width: 800px; + min-width: 50%; + margin: 15px 20px; display: flex; flex-direction: column; @@ -71,24 +83,29 @@ a[folder][parent] + a[folder]:not([parent]) { .entry .expander { display: flex; + align-items: center; gap: 5px; user-select: none; - cursor: pointer; } .entry .expander::before { - content: "+ "; + content: "+"; + font-size: 1.3em; + margin-right: 0.2em; + cursor: pointer; } .entry[open] .expander::before { - content: "- "; + content: "-"; } -.entry .expander .comment { +.entry .expander a { + text-decoration: none; flex-grow: 1; } .entry .close { font-size: 0.85em; + cursor: pointer; } .entry .details { @@ -159,4 +176,111 @@ a[folder][parent] + a[folder]:not([parent]) { .comment { color: #75715e; font-style: italic; +} + + +#search { + position: relative; + padding: 0px; + margin: 0px; + + max-width: 800px; + width: 100%; + + display: flex; + justify-content: center; +} + +#search > * { + display: inline-block; + position: relative; + + padding: 0rem 10px; + width: 300px; + + transition-property: width; + transition-duration: .2s; +} + + +#search input { + position: relative; + inset: 0; + + padding: 0.0rem 1rem; + margin: 0px; + + width: calc(100% - 2rem); + height: 45px; + + background-color: transparent; + font-size: 0.8em; + border: none; + color: inherit; + + opacity: 0; + z-index: 2; + + transition-property: opacity; + transition-duration: .1s; + transition-timing-function: ease-out; +} + +#search .placeholder { + position: absolute; + inset: 12px 0px 0px; + + height: 20px; + width: 340px; + + font-weight: 100; + text-align: center; + font-size: 0.8em; + color: inherit; + + opacity: 100; + z-index: 1; + + transition-property: opacity; + transition-duration: .1s; + transition-timing-function: ease-out; +} + +#search:focus-within > * { + width: calc(100% - 2rem); +} +#search:focus-within input { + opacity: 100; +} +#search:focus-within .placeholder { + opacity: 0; +} + + +#search .results { + display: none; + + position: absolute; + top: 45px; left: 15px; right: 0; + z-index: 20; + + padding: 10px 0px; + + flex-direction: column; + + background-color: #1e1f1c; + filter: drop-shadow(0px 4px 4px #0002); +} + +#search:focus-within .results { + display: flex; +} + +#search .result { + text-decoration: none; + padding: 5px 20px; +} +#search .result .comment { + font-style: normal; + margin-left: 0.5em; } \ No newline at end of file