From 9d7f83f838633c372dd62c64e2afa4c120dc58e0 Mon Sep 17 00:00:00 2001 From: MindfulMinun Date: Sun, 26 Jun 2022 11:34:26 -0500 Subject: [PATCH] initial commit --- lib/dom.ts | 99 +++++++++++++++++ lib/helpers.ts | 109 +++++++++++++++++++ lib/iterable.ts | 213 +++++++++++++++++++++++++++++++++++++ lib/mod.ts | 12 +++ lib/shell.ts | 15 +++ lib/tests/iterable.test.ts | 40 +++++++ readme.md | 1 + 7 files changed, 489 insertions(+) create mode 100644 lib/dom.ts create mode 100644 lib/helpers.ts create mode 100644 lib/iterable.ts create mode 100644 lib/mod.ts create mode 100644 lib/shell.ts create mode 100644 lib/tests/iterable.test.ts create mode 100644 readme.md diff --git a/lib/dom.ts b/lib/dom.ts new file mode 100644 index 0000000..45d553c --- /dev/null +++ b/lib/dom.ts @@ -0,0 +1,99 @@ +/// +import { templateNoop, HTML_ESCAPES, xss } from "./helpers.ts" + +export { HTML_ESCAPES } + +/** + * Parses HTML, kinda smart. + * + * As a function, it just parses the string as HTML. When used as a tagged template, expressions are XSS escaped using {@link xss}, except Nodes, which are added to the tree. + * + * ```js + * html("

Hello world!

") + * // DocumentFragment + * // └─ p + * // └─ "Hello world!" + * html` + * ${html`Look ma, nested HTML!`} + * ${``} + * ` + * // DocumentFragment + * // └─ strong + * // ├─ em + * // │ └─ "Look ma, nested HTML!" + * // └─ "" + * ``` + * @author MindfulMinun + * @since 2020-06-23 + */ +export function html(content: string): DocumentFragment +export function html(strings: TemplateStringsArray, ...exprs: any[]): DocumentFragment +export function html(strings: TemplateStringsArray | string, ...exprs: any[]): DocumentFragment { + const temp = document.createElement("template") + let out = "" + + if (typeof strings === "string") { + temp.innerHTML = strings + return temp.content.cloneNode(true) as DocumentFragment + } + + // Join the strings together + for (let i = 0; i < strings.length; i++) { + // If it's another Node, put a template in its place so we can replace it later + if (exprs[i] instanceof Node) { + out += strings[i] + `` + continue + } + // Otherwise, just join the expressions together, escaping dynamic content + out += strings[i] + (exprs[i] != null ? xss("" + exprs[i]) : "") + } + temp.innerHTML = out + const clone = temp.content.cloneNode(true) as DocumentFragment + + // Replace all templates with the real nodes + clone.querySelectorAll("template[data-replaceindex]").forEach(node => { + if (!(node instanceof HTMLElement)) return + if (!node.parentNode) return + const index = parseInt(node.getAttribute("data-replaceindex") || "", 10) + node.parentNode.replaceChild(exprs[index], node); + }) + + return clone +} + +/** + * Creates a text node for use in the DOM. The text content is NOT escaped. + * @author MindfulMinun + * @since 2022-06-04 + */ +export function textNode(templ: string): Text +export function textNode(templ: TemplateStringsArray, ...values: any[]): Text +export function textNode(templ: string | TemplateStringsArray, ...values: any[]): Text { + const string = templateNoop(templ, ...values) + return document.createTextNode(string) +} + +/** + * Walks the DOM recursively, calling the callback for each node. + * @deprecated + * @param {Node} root The root of the DOM walk + * @param {number} [filters] A bitmask of what nodes to show. Defaults to `NodeFilter.SHOW_ELEMENT`. + * @param {(node: Node, root: Node, walker: TreeWalker) => boolean} callback If this callback returns true, stop tree traversal. + * @author MindfulMinun + * @since 2020-06-29 + */ +export function domWalker( + root: Node, + callback: (node: Node, root: Node, walker: TreeWalker) => boolean, + filters: number = NodeFilter.SHOW_ELEMENT +) { + const walker = document.createTreeWalker(root, filters); + let guard = false; + let current: Node | null = walker.currentNode + while (true) { + if (!current) break + if (guard) break + guard = callback.call(null, current, root, walker); + current = walker.nextNode() + } +} diff --git a/lib/helpers.ts b/lib/helpers.ts new file mode 100644 index 0000000..8c206d9 --- /dev/null +++ b/lib/helpers.ts @@ -0,0 +1,109 @@ +/** + * HTML escape sequences for unsafe characters + */ +export const HTML_ESCAPES = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/" +} + +/** + * Removes indentation from multiline strings. + * @since 2021-08-07 + */ +export function dedent( + templ: TemplateStringsArray | string, + ...values: unknown[] +): string { + let strings = Array.from(typeof templ === 'string' ? [templ] : templ) + + // 1. Remove trailing whitespace. + strings[strings.length - 1] = strings[strings.length - 1].trimRight() + + // 2. Find all line breaks to determine the highest common indentation level. + const indentLengths = strings.reduce((arr, str) => { + const matches = str.match(/\n([\t ]+|(?!\s).)/g) + if (matches) { + return arr.concat( + matches.map((match) => match.match(/[\t ]/g)?.length ?? 0) + ) + } + return arr + }, []) + + // 3. Remove the common indentation from all strings. + if (indentLengths.length) { + const pattern = new RegExp(`\n[\t ]{${Math.min(...indentLengths)}}`, 'g') + + strings = strings.map((str) => str.replace(pattern, '\n')) + } + + // 4. Remove leading whitespace. + strings[0] = strings[0].trimStart() + + // 5. Perform interpolation. + let string = strings[0] + + values.forEach((value, i) => { + // 5.1 Read current indentation level + const endentations = string.match(/(?:^|\n)( *)$/) + const endentation = endentations ? endentations[1] : '' + let indentedValue = value + // 5.2 Add indentation to values with multiline strings + if (typeof value === 'string' && value.includes('\n')) { + indentedValue = String(value) + .split('\n') + .map((str, i) => i === 0 ? str : `${endentation}${str}`) + .join('\n') + } + + string += indentedValue + strings[i + 1] + }) + + return string +} + +/** + * A no-operation function for tagged template string literals + * @author MindfulMinun + * @since 2022-05-20 + */ +export function templateNoop( + templ: TemplateStringsArray | string, + ...values: unknown[] +): string { + let strings = Array.from(typeof templ === 'string' ? [templ] : templ) + return strings.reduce((result, currentString, i) => + result + currentString + (values[i] != null ? values[i] : ''), + '' // Start with the empty string + ) +} + +/** + * Promise that resolves after timeout + * + * ```js + * // Play hide and seek, count to 100 (seconds) + * wait(100e3).then(() => "Ready or not, here I come!").then(seek) + * ``` + * @since 2020-06-23 + */ +export function wait(timeout: number): Promise { + return new Promise(resolve => setTimeout(resolve, timeout)) +} + +/** + * Escapes unsafe strings for use in HTML + * + * ```js + * xss(``) + * ``` + * @author MindfulMinun + * @since 2020-06-23 + */ +export function xss(unsafe: string): string { + return unsafe.replace(/[&<>"'\/]/g, (key) => HTML_ESCAPES[key as keyof typeof HTML_ESCAPES]) +} diff --git a/lib/iterable.ts b/lib/iterable.ts new file mode 100644 index 0000000..5c73975 --- /dev/null +++ b/lib/iterable.ts @@ -0,0 +1,213 @@ +/** + * Represents a tuple, or a homogenous array of a fixed length. + * + * const deck: Tuple = [Card, Card, Card, ...]; + */ +export type Tuple< + T, + N extends number, + R extends readonly T[] = [], +> = R['length'] extends N ? R : Tuple; + +/** + * Chooses a random element from an array + * + * choose(document.all).click() + * @since 2020-06-29 + */ +export function choose(arr: T[]): T { + if (!arr.length) throw new Error('Cannot choose from an empty array') + return arr[Math.floor(Math.random() * arr.length)] +} + +/** + * Creates an iterable that always yields the given value. + * @author MindfulMinun + * @since 2022-04-21 + */ +export function* fill(value = 0 as unknown as T, length = Infinity): Generator { + for (let i = 0; i < length; i++) + yield value +} + +/** + * Creates an iterable that yields numbers between a range. + * @author MindfulMinun + * @since 2022-04-27 + */ +export function range(end: number): Generator +export function range(start: number, end: number): Generator +export function range(start: number, end: number, step: number): Generator +export function* range(a: number, b?: number, c?: number) { + const [start, end, step] = ( + typeof b === "undefined" ? [0, a, 1] : + typeof c === "undefined" ? [a, b, 1] : [a, b, Math.abs(c)] + ) + const delta = end < start ? -step : step + for (let i = start; i < end; i += delta) + yield i +} + +/** + * Removes elements from an array. Non-destructive, as all array operations should be :D + * + * // Removes 'b', at index 1 + * removeFromArraybyIndexes(['a', 'b', 'c'], 1) // ['a', 'c'] + * // Removes 'b' and 'd', at indexes 1 and 3 + * removeFromArraybyIndexes(['a', 'b', 'c', 'd'], [1, 3]) // ['a', 'c'] + * @since 2020-07-12 + */ +export function removeFromArraybyIndexes( + arr: T[], + indexes: number | number[] +): T[] { + if (Array.isArray(indexes)) { + return arr.filter((_, i) => indexes.indexOf(i) === -1) + } else { + return arr.filter((_, i) => i !== indexes) + } +} + +/** + * Shuffles an array. Note that this function swaps the elements + * *in place*, meaning the original array is modified. + * @since 2020-07-19 + */ +export function shuffle(arr: T[]): T[] { + let j, i + for (i = arr.length - 1; i > 0; i--) { + j = Math.floor(Math.random() * (i + 1)) + swap(arr, i, j) + } + return arr +} + +/** + * Swaps two elements of an array. Note that this function swaps + * the elements *in place*, meaning the original array is modified. + * @since 2020-07-19 + */ +export function swap(arr: T[], i: number, j: number): void { + let carry = arr[i] + arr[i] = arr[j] + arr[j] = carry +} + +/** + * Helper class to handle events and turn them into async iterables + * + * ```ts + * const stream = new EventStream() + * + * document.body.addEventListener('click', ev => stream.emit(ev)) + * wait(10e3).then(() => stream.end()) + * + * for await (const event of stream) { + * alert("Clicked!") + * } + * ``` + * @since 2021-08-07 + */ +export class EventStream { + #done: boolean + #events: T[] + #resolve: () => void + #promise!: Promise + + constructor() { + this.#done = false + this.#events = [] + this.#resolve = () => { } + this.#defer() + } + + #defer() { + this.#promise = new Promise(r => this.#resolve = r) + } + + async*[Symbol.asyncIterator]() { + while (!this.#done) { + await this.#promise + yield* this.#events + this.#events = [] + } + } + + /** + * Dispatches an event. For-await-of listeners of this class instance will recieve this event. + * Note that once an event is emitted, it cannot be cancelled. + * @since 2021-08-07 + */ + emit(event: T) { + this.#events.push(event) + this.#resolve() + this.#defer() + } + + /** + * Waits for a specific event to be emitted according to a predicate. + * + * @example + * const stream = new EventStream() + * // Some events happen... + * const click = stream.once(ev => ev.type === 'click') + * @since 2021-12-22 + */ + once(predicate: (value: T) => boolean) { + return once(this, predicate) + } + + /** + * Stops the iterator. This terminates for-await-of loops listening for events, + * and any newly dispatched events will not be sent to the iterator. + * @since 2021-08-07 + */ + end() { + this.#done = true + this.#resolve() + } +} + +/** + * Wait for any async iterable to yield a specific value according to a predicate. + * + * @example + * const stream = new EventStream() + * // Some events happen... + * const click = once(stream, ev => ev.type === 'click') + * @since 2021-12-22 + */ +export async function once(source: AsyncIterable, predicate: (value: T) => boolean) { + for await (const value of source) { + if (predicate(value)) return value + } +} + +/** + * A "destructured" Promise, useful for waiting for callbacks. + * @example + * // When awaited, this function returns a promise that resolves to + * // an *OPEN* WebSocket + * () => { + * const sock = new WebSocket() + * const [p, res, rej] = pinkyPromise() + * sock.onerror = err => rej(err) + * sock.onopen = () => res(sock) + * return p + * } + * + * @author MindfulMinun + * @since 2022-06-05 + */ +export function pinkyPromise(): [Promise, (value: T) => void, (error: any) => unknown] { + let resolve: (value: T) => void + let reject: (error: unknown) => void + + // This works because callbacks to the Promise constructor are called synchronously. + const p = new Promise((yay, nay) => { + resolve = yay + reject = nay + }) + + return [p, resolve!, reject!] +} diff --git a/lib/mod.ts b/lib/mod.ts new file mode 100644 index 0000000..d8a09b7 --- /dev/null +++ b/lib/mod.ts @@ -0,0 +1,12 @@ +/** + * Code I keep on rewriting for some reason + * @author MindfulMinun + * @license MIT + */ + +console.error("Hey there, past me! Please don't import , instead import specific files please!") + +// export * from "./array.ts" +// export * from "./helpers.ts" +// export * from "./math.ts" +// export * from "./dom.ts" diff --git a/lib/shell.ts b/lib/shell.ts new file mode 100644 index 0000000..e81359c --- /dev/null +++ b/lib/shell.ts @@ -0,0 +1,15 @@ +/** + * Run a shell command that can be aborted using an `AbortSignal` + * @since 2021-08-13 + */ +export async function abortableExec(opts: { cmd: string[], signal?: AbortSignal }): Promise { + const { cmd, signal } = opts + if (signal && signal.aborted) return + const p = Deno.run({ + cmd, + stdout: "null" + }) + signal && signal.addEventListener('abort', () => p.kill("SIGINT")) + await p.status() + return +} diff --git a/lib/tests/iterable.test.ts b/lib/tests/iterable.test.ts new file mode 100644 index 0000000..86d674f --- /dev/null +++ b/lib/tests/iterable.test.ts @@ -0,0 +1,40 @@ +import { + choose, fill, range, removeFromArraybyIndexes, shuffle, swap, once +} from "../iterable.ts" + +import { assertThrows, assertEquals, assert } from "https://deno.land/std@0.140.0/testing/asserts.ts" + +Deno.test("choose: throw on empty array", () => { + assertThrows(() => choose([])) +}) + +Deno.test("fill, yield the same value every time", () => { + const count = 50_000 + const item = "hi" + let i = 0 + for (const el of fill(item, count)) { + assertEquals(el, item) + i++ + } + assertEquals(i, count) +}) + + +Deno.test("range: one arg", () => { + const iter = range(50_000) + for (let i = 0; i < 50_000; i++) { + const next = iter.next() + assertEquals(next.value, i) + } + assert(iter.next().done) +}) + + +Deno.test("range: two args", () => { + const iter = range(50_000) + for (let i = 0; i < 50_000; i++) { + const next = iter.next() + assertEquals(next.value, i) + } + assert(iter.next().done) +}) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8030c1c --- /dev/null +++ b/readme.md @@ -0,0 +1 @@ +# pear 🍐