site/utils.mjs (352 lines of code) (raw):

// @ts-check /** * @import { CreateElementOptions } from "./@types/globals" */ /** * @param {any} any * @returns {any} */ export function asAny(any) { return any; } /** * @param {URLSearchParams} urlParams */ export function changeLocation(urlParams) { const url = new URL(window.location.href); const newLocation = `${url.origin}${url.pathname}?${urlParams}`; // @ts-ignore window.location = newLocation; } /** * @param {string} key * @param {any} value */ export function exposeAsGlobal(key, value) { console.log(key, value); asAny(window)[key] = value; } /** * Gets an element and throws if it doesn't exists. The className provided to specialize * the type. Note the `any` coercion for the HTMLElement is working around an issue * where TypeScript complains about the types. This workaround makes it so that the * types are correctly inferred, and there are no runtime errors. * * @template {HTMLElement} T * * @param {string} id * @param {{ new (): T }} className * @returns {T} */ export function getElement(id, className = /** @type {any} */ (HTMLElement)) { const element = document.getElementById(id); if (!element) { throw new Error("Could not find element by id: " + id); } if (!(element instanceof className)) { throw new Error( `Selected element #${id} was not an instance of ${className.name}` ); } return element; } /** * Helper to create a table row, and add TD elements. * * @param {HTMLElement} tbody * @param {Element?} [insertBefore] */ export function createTableRow(tbody, insertBefore) { const tr = document.createElement("tr"); tbody.insertBefore(tr, insertBefore ?? null); return { tr, /** * @param {string | Element} [textOrEl] * @returns {HTMLTableCellElement} */ createTD(textOrEl = "") { const el = document.createElement("td"); if (typeof textOrEl === "string") { el.innerText = textOrEl; } else { el.appendChild(textOrEl); } tr.appendChild(el); return el; }, }; } /** * Helper to create an <a href> tag. * * @param {string} text * @param {string} [href] */ export function createLink(text, href) { const a = document.createElement("a"); if (href) { a.href = href; } a.innerText = text; return a; } /** * Helper to create a button with an action. * * @param {string | Element} textOrEl * @param {(this: HTMLButtonElement, event: MouseEvent) => unknown} callback */ export function createButton(textOrEl, callback) { const button = document.createElement("button"); button.addEventListener("click", callback); if (typeof textOrEl === "string") { button.innerText = textOrEl; } else { button.appendChild(textOrEl); } return button; } /** * Formats a number of bytes into a human-readable string. * * @param {number} bytes * @param {number} [decimals] * @returns {string} */ export function formatBytes(bytes, decimals = 2) { if (bytes === 0) return "0 B"; const k = 1000; const dm = decimals < 0 ? 0 : decimals; const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } /** * @typedef {object} SearchFilters * @property {string} key * @property {string} value * @property {boolean} negated */ /** * AI-generated search query parser, manually tweaked. * * > Write a JS parser for the following search syntax: * > * > name:search-term date:>2025-01-02 -language:french * > * > term1 term2 * > * > "quoted term" * > * > name:"quoted term" date:<2025-01-12 * * @param {string} query * @returns {{ filters: SearchFilters[], terms: string[] }} */ export function parseSearchQuery(query) { const fieldPattern = /(?:^|\s)(-?)(\w+):(\"[^\"]+\"|[^\s]+)/g; const unstructuredPattern = /(?:^|\s)(\"[^\"]+\"|\S+)/g; let match; /** @type {SearchFilters[]} */ const filters = []; /** @type {string[]} */ const terms = []; const seenIndices = new Set(); // Extract field-based filters while ((match = fieldPattern.exec(query)) !== null) { const [, negation, key, rawValue] = match; const value = rawValue.replace(/^\"|\"$/g, "").trim(); if (value) { filters.push({ key: key.toLowerCase(), value: value.toLowerCase(), negated: !!negation, }); } seenIndices.add(match.index); } // Extract unstructured search terms while ((match = unstructuredPattern.exec(query)) !== null) { if (!seenIndices.has(match.index)) { const value = match[1].replace(/^\"|\"$/g, ""); if (value.trim()) { terms.push(value.toLowerCase()); } } } return { filters, terms }; } /** * @param {URLSearchParams} urlParams */ export function pushLocation(urlParams) { const url = new URL(window.location.href); const newLocation = `${url.origin}${url.pathname}?${urlParams}`; history.pushState(null, "", newLocation); } /** * @param {any} object */ export function jsonToYAML(object, indent = 0) { const spaces = " ".repeat(indent); let yaml = ""; for (const [key, value] of Object.entries(object)) { const formattedKey = /^[a-zA-Z0-9_-]+$/.test(key) ? key : `'${key}'`; if (value === null) { yaml += `${spaces}${formattedKey}: null\n`; } else if (typeof value === "boolean" || typeof value === "number") { yaml += `${spaces}${formattedKey}: ${value}\n`; } else if (typeof value === "string") { yaml += `${spaces}${formattedKey}: ${ value.includes(":") || value.includes("\n") ? `|\n${spaces} ` + value.replace(/\n/g, `\n${spaces} `) : value }\n`; } else if (Array.isArray(value)) { if (value.length === 0) { yaml += `${spaces}${formattedKey}: []\n`; } else { yaml += `${spaces}${formattedKey}:\n`; for (const item of value) { yaml += `${spaces} - ${ typeof item === "object" ? "\n" + jsonToYAML(item, indent + 2) : item }\n`; } } } else if (typeof value === "object") { yaml += `${spaces}${formattedKey}:\n` + jsonToYAML(value, indent + 1); } } return yaml; } /** * A type-only check that a type is "never" * @param {never} never */ export function isNever(never) {} /** * A utility function to make it easier to create HTML elements declaratively. * * @template {keyof HTMLElementTagNameMap} T * * @param {T} tagName * @param {Partial<CreateElementOptions>} [options] */ export function createElement(tagName, options) { const element = document.createElement(tagName); if (options) { const { style, parent, children, href, className, title, onClick } = options; if (style) { Object.assign(element.style, style); } if (href !== undefined) { if (element instanceof HTMLAnchorElement) { element.href = href; } else { throw new Error("An href was provided for a non-anchor element."); } } if (typeof children === "string") { element.innerText = children; } else if (Array.isArray(children)) { for (const child of children) { if (typeof child === "string") { element.appendChild(new Text(child)); } else { element.appendChild(child); } } } else if (children instanceof Node) { element.appendChild(children); } else if (children) { // Ensure we've handled all of the cases. isNever(children); } if (className) { element.className = className; } if (title) { element.title = title; } if (onClick) { if (element instanceof HTMLButtonElement) { element.addEventListener("click", onClick); } else { throw new Error( "The createElement util needs support for this onClick handler" ); } } // Append it last to avoid unnecessary jank. if (parent) { parent.appendChild(element); } } return element; } /** * A subset of supported tag names, feel free to add more tag names. */ const tagNames = [ /** @type {const} */ ("a"), /** @type {const} */ ("br"), /** @type {const} */ ("button"), /** @type {const} */ ("div"), /** @type {const} */ ("h1"), /** @type {const} */ ("h2"), /** @type {const} */ ("h3"), /** @type {const} */ ("h4"), /** @type {const} */ ("li"), /** @type {const} */ ("p"), /** @type {const} */ ("pre"), /** @type {const} */ ("span"), /** @type {const} */ ("table"), /** @type {const} */ ("tbody"), /** @type {const} */ ("thead"), /** @type {const} */ ("th"), /** @type {const} */ ("td"), /** @type {const} */ ("tr"), /** @type {const} */ ("ul"), ]; /** * @typedef {(typeof tagNames)[number]} TagNames */ /** * @typedef {typeof createElement} CreateElement */ /** * Exports the createElement interface in a convenient partially applied interface that * can autocomplete. To support additional tag names, add the tag to the tagNames list. * * The simplified type for the interface is (the HTMLElement is specialized to the specific type). * * (options?: Partial<CreateElementOptions>) => HTMLElement * * @see {CreateElementOptions} * * Instead of: * * const coolDiv = createElement("div", { * className: "cool-div", * }); * * You can write: * * const coolDiv = create.div({ * className: "cool-div", * }); * * @type {{ [K in TagNames]: (options?: Partial<CreateElementOptions>) => HTMLElementTagNameMap[K]; }} */ export const create = /** @type {any} */ ({}); // Create the partially applied createElement functions. for (const tagName of tagNames) { /** * @type {(options?: Partial<CreateElementOptions>) => any} */ create[tagName] = (options) => createElement(tagName, options); }