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:
saberzero1 2026-02-09 12:45:22 +01:00
parent af008ed45f
commit c997522a5f
No known key found for this signature in database
5 changed files with 257 additions and 1186 deletions

1222
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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,

View file

@ -1,5 +1 @@
export function removeAllChildren(el: HTMLElement): void {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
export { removeAllChildren } from "@quartz-community/utils";

View file

@ -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",
};
});
},