mirror of
https://github.com/quartz-community/search.git
synced 2026-07-22 02:50:25 +00:00
feat: use @quartz-community/utils for shared utilities
- Add @quartz-community/utils dependency - Update tsup config to bundle inline scripts with esbuild - Import removeAllChildren and normalizeRelativeURLs from utils - Re-export removeAllChildren from util.ts for backward compatibility - Remove unused template dependencies
This commit is contained in:
parent
af008ed45f
commit
c997522a5f
5 changed files with 257 additions and 1186 deletions
1222
package-lock.json
generated
1222
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -65,15 +65,8 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@quartz-community/types": "file:../types",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.3"
|
||||
"@quartz-community/types": "github:quartz-community/types",
|
||||
"@quartz-community/utils": "github:quartz-community/utils"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/hast": "^3.0.4",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// @ts-expect-error flexsearch lacks types in this repo
|
||||
import FlexSearch from "flexsearch";
|
||||
import { removeAllChildren } from "../util";
|
||||
import { removeAllChildren, normalizeRelativeURLs } from "@quartz-community/utils";
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
|
|
@ -76,11 +77,29 @@ const index = new FlexSearch.Document({
|
|||
|
||||
let contentData: Record<string, Item> | null = null;
|
||||
let idDataMap: string[] = [];
|
||||
const fetchContentCache = new Map<string, Element[]>();
|
||||
const parser = new DOMParser();
|
||||
|
||||
async function fetchContent(slug: string): Promise<Element[]> {
|
||||
if (fetchContentCache.has(slug)) {
|
||||
return fetchContentCache.get(slug) as Element[];
|
||||
}
|
||||
const targetUrl = new URL("/" + slug, window.location.origin).toString();
|
||||
const contents = await fetch(targetUrl)
|
||||
.then((res) => res.text())
|
||||
.then((contents) => {
|
||||
const html = parser.parseFromString(contents ?? "", "text/html");
|
||||
normalizeRelativeURLs(html, targetUrl);
|
||||
return Array.from(html.getElementsByClassName("popover-hint"));
|
||||
});
|
||||
fetchContentCache.set(slug, contents);
|
||||
return contents;
|
||||
}
|
||||
|
||||
async function setupSearch() {
|
||||
const searchElements = document.querySelectorAll(".search");
|
||||
|
||||
for (const searchEl of searchElements) {
|
||||
for (const searchEl of Array.from(searchElements)) {
|
||||
const container = searchEl.querySelector(".search-container");
|
||||
const searchButton = searchEl.querySelector(".search-button");
|
||||
const searchBar = searchEl.querySelector(".search-bar") as HTMLInputElement;
|
||||
|
|
@ -100,6 +119,9 @@ async function setupSearch() {
|
|||
searchLayout.appendChild(preview);
|
||||
}
|
||||
|
||||
let currentHover: HTMLElement | null = null;
|
||||
let previewToken = 0;
|
||||
|
||||
const hideSearch = () => {
|
||||
container.classList.remove("active");
|
||||
searchBar.value = "";
|
||||
|
|
@ -107,6 +129,7 @@ async function setupSearch() {
|
|||
if (preview) removeAllChildren(preview);
|
||||
searchLayout.classList.remove("display-results");
|
||||
searchType = "basic";
|
||||
currentHover = null;
|
||||
};
|
||||
|
||||
const showSearch = (type: SearchType) => {
|
||||
|
|
@ -121,6 +144,8 @@ async function setupSearch() {
|
|||
if (finalResults.length === 0) {
|
||||
results.innerHTML =
|
||||
'<a class="result-card no-match"><h3>No results.</h3><p>Try another search term?</p></a>';
|
||||
currentHover = null;
|
||||
if (preview) removeAllChildren(preview);
|
||||
} else {
|
||||
for (const item of finalResults) {
|
||||
const htmlTags =
|
||||
|
|
@ -141,16 +166,85 @@ async function setupSearch() {
|
|||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
hideSearch();
|
||||
});
|
||||
itemTile.addEventListener("mouseenter", () => {
|
||||
setFocus(itemTile);
|
||||
});
|
||||
results.appendChild(itemTile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getResultElements = (): HTMLElement[] => {
|
||||
return Array.from(results.querySelectorAll<HTMLElement>(".result-card:not(.no-match)"));
|
||||
};
|
||||
|
||||
const highlightTerm = () => {
|
||||
return searchType === "tags" ? currentSearchTerm.substring(1).trim() : currentSearchTerm;
|
||||
};
|
||||
|
||||
const updatePreview = async (el: HTMLElement | null) => {
|
||||
if (!preview) return;
|
||||
removeAllChildren(preview);
|
||||
if (!el) return;
|
||||
const slug = el.id;
|
||||
const token = ++previewToken;
|
||||
const contents = await fetchContent(slug);
|
||||
if (token !== previewToken) return;
|
||||
const term = highlightTerm();
|
||||
for (const contentEl of contents) {
|
||||
const cloned = contentEl.cloneNode(true) as HTMLElement;
|
||||
if (term.trim() !== "") {
|
||||
cloned.innerHTML = highlightHTML(term, cloned);
|
||||
}
|
||||
preview.appendChild(cloned);
|
||||
}
|
||||
};
|
||||
|
||||
const setFocus = (el: HTMLElement | null) => {
|
||||
if (currentHover) currentHover.classList.remove("focus");
|
||||
currentHover = el;
|
||||
if (currentHover) currentHover.classList.add("focus");
|
||||
updatePreview(currentHover);
|
||||
};
|
||||
|
||||
const focusByIndex = (index: number) => {
|
||||
const resultElements = getResultElements();
|
||||
if (resultElements.length === 0) {
|
||||
setFocus(null);
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(Math.max(index, 0), resultElements.length - 1);
|
||||
setFocus(resultElements[clamped] ?? null);
|
||||
};
|
||||
|
||||
const focusNext = () => {
|
||||
const resultElements = getResultElements();
|
||||
if (resultElements.length === 0) return;
|
||||
const currentIndex = currentHover ? resultElements.indexOf(currentHover) : -1;
|
||||
focusByIndex(currentIndex + 1);
|
||||
};
|
||||
|
||||
const focusPrevious = () => {
|
||||
const resultElements = getResultElements();
|
||||
if (resultElements.length === 0) return;
|
||||
const currentIndex = currentHover
|
||||
? resultElements.indexOf(currentHover)
|
||||
: resultElements.length;
|
||||
focusByIndex(currentIndex - 1);
|
||||
};
|
||||
|
||||
const onType = async (e: Event) => {
|
||||
currentSearchTerm = (e.target as HTMLInputElement).value;
|
||||
searchLayout.classList.toggle("display-results", currentSearchTerm !== "");
|
||||
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic";
|
||||
|
||||
if (currentSearchTerm === "") {
|
||||
removeAllChildren(results);
|
||||
if (preview) removeAllChildren(preview);
|
||||
currentHover = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const query = currentSearchTerm;
|
||||
let searchResults: any[];
|
||||
|
||||
|
|
@ -184,10 +278,30 @@ async function setupSearch() {
|
|||
});
|
||||
|
||||
await displayResults(finalResults.slice(0, numSearchResults));
|
||||
const resultElements = getResultElements();
|
||||
setFocus(resultElements[0] ?? null);
|
||||
};
|
||||
|
||||
searchButton.addEventListener("click", () => showSearch("basic"));
|
||||
searchBar.addEventListener("input", onType);
|
||||
searchBar.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||
e.preventDefault();
|
||||
focusPrevious();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
focusNext();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
const focused = currentHover;
|
||||
if (focused instanceof HTMLAnchorElement) {
|
||||
window.location.href = focused.href;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
|
|
@ -215,6 +329,48 @@ function tokenizeTerm(term: string): string[] {
|
|||
return tokens.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function highlightHTML(searchTerm: string, el: HTMLElement): string {
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm).filter((term) => term.trim() !== "");
|
||||
if (tokenizedTerms.length === 0) return el.innerHTML;
|
||||
const html = parser.parseFromString(el.innerHTML, "text/html");
|
||||
const combined = tokenizedTerms
|
||||
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("|");
|
||||
if (combined === "") return el.innerHTML;
|
||||
const regex = new RegExp(combined, "gi");
|
||||
const walker = html.createTreeWalker(html.body, NodeFilter.SHOW_TEXT);
|
||||
const nodes: Text[] = [];
|
||||
let node: Text | null = walker.nextNode() as Text | null;
|
||||
while (node) {
|
||||
nodes.push(node);
|
||||
node = walker.nextNode() as Text | null;
|
||||
}
|
||||
for (const textNode of nodes) {
|
||||
const text = textNode.nodeValue ?? "";
|
||||
regex.lastIndex = 0;
|
||||
if (!regex.test(text)) continue;
|
||||
regex.lastIndex = 0;
|
||||
const fragment = html.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
fragment.appendChild(html.createTextNode(text.slice(lastIndex, match.index)));
|
||||
}
|
||||
const span = html.createElement("span");
|
||||
span.className = "highlight";
|
||||
span.textContent = match[0];
|
||||
fragment.appendChild(span);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
fragment.appendChild(html.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||
}
|
||||
return html.body.innerHTML;
|
||||
}
|
||||
|
||||
function highlight(searchTerm: string, text: string, trim?: boolean): string {
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm);
|
||||
const tokenizedText = text.split(/\s+/).filter((t) => t !== "");
|
||||
|
|
@ -277,7 +433,25 @@ function highlightTags(term: string, tags?: string[]): string[] {
|
|||
|
||||
function formatForDisplay(term: string, id: number): any {
|
||||
const slug = idDataMap[id];
|
||||
const data = contentData![slug];
|
||||
if (!slug || !contentData) {
|
||||
return {
|
||||
id,
|
||||
slug: "",
|
||||
title: "",
|
||||
content: "",
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
const data = contentData[slug];
|
||||
if (!data) {
|
||||
return {
|
||||
id,
|
||||
slug,
|
||||
title: "",
|
||||
content: "",
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: id,
|
||||
slug: slug,
|
||||
|
|
@ -288,9 +462,11 @@ function formatForDisplay(term: string, id: number): any {
|
|||
}
|
||||
|
||||
async function fillDocument() {
|
||||
if (!contentData) return;
|
||||
let id = 0;
|
||||
for (const slug in contentData) {
|
||||
const fileData = contentData![slug];
|
||||
for (const slug of Object.keys(contentData)) {
|
||||
const fileData = contentData[slug];
|
||||
if (!fileData) continue;
|
||||
idDataMap[id] = slug;
|
||||
await index.addAsync(id, {
|
||||
id: id,
|
||||
|
|
|
|||
|
|
@ -1,5 +1 @@
|
|||
export function removeAllChildren(el: HTMLElement): void {
|
||||
while (el.firstChild) {
|
||||
el.removeChild(el.firstChild);
|
||||
}
|
||||
}
|
||||
export { removeAllChildren } from "@quartz-community/utils";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { defineConfig } from "tsup";
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
|
|
@ -31,11 +32,20 @@ export default defineConfig({
|
|||
});
|
||||
|
||||
build.onLoad({ filter: /\.inline\.ts$/ }, async (args) => {
|
||||
const fs = await import("fs");
|
||||
const text = await fs.promises.readFile(args.path, "utf8");
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [args.path],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
target: "es2022",
|
||||
minify: false,
|
||||
platform: "browser",
|
||||
external: ["flexsearch"],
|
||||
});
|
||||
const code = result.outputFiles?.[0]?.text ?? "";
|
||||
return {
|
||||
contents: text,
|
||||
loader: "text",
|
||||
contents: `export default ${JSON.stringify(code)};`,
|
||||
loader: "ts",
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue