diff --git a/modules/utils/createTrackedResponse.mjs b/modules/utils/createTrackedResponse.mjs index 4e59171..9b2cae7 100644 --- a/modules/utils/createTrackedResponse.mjs +++ b/modules/utils/createTrackedResponse.mjs @@ -69,8 +69,15 @@ export const createTrackedResponse = ( const start = readerPromise.resolve; - return Object.assign(successPromise, { + /** @type {Promise} */ + return { start, + /** @type {Promise["then"]} */ + then: successPromise.then.bind(successPromise), + /** @type {Promise["catch"]} */ + catch: successPromise.catch.bind(successPromise), + /** @type {Promise["finally"]} */ + finally: successPromise.finally.bind(successPromise), get response() { return response; }, @@ -89,5 +96,7 @@ export const createTrackedResponse = ( get isUnknown() { return success === false && failed === false; }, - }); + }; }; + +export default createTrackedResponse; diff --git a/modules/utils/deferredPromise.mjs b/modules/utils/deferredPromise.mjs index 51980e8..5680822 100644 --- a/modules/utils/deferredPromise.mjs +++ b/modules/utils/deferredPromise.mjs @@ -24,4 +24,4 @@ export const deferredPromise = () => { return Object.assign(promise, {resolve, reject}); } -export default DeferredPromise \ No newline at end of file +export default deferredPromise \ No newline at end of file diff --git a/modules/utils/getPageUniqueId.mjs b/modules/utils/getPageUniqueId.mjs new file mode 100644 index 0000000..b31a2fb --- /dev/null +++ b/modules/utils/getPageUniqueId.mjs @@ -0,0 +1,25 @@ +//@ts-check +import {getReasonableUuid} from "./getReasonableUuid.mjs" + +/** + * Returns an id that is guaranteed to not exist in the current loaded page. + * Only usable in the browser, after the page has loaded. + * Using it outside the browser or prior to loading will yield a pseudo-unique id, + * but not guaranteed unique. + * Ids are deterministic, so calling this function in the same order will always return + * the same set of ids. + * @see {uuid} + * @param {string} prefix any prefix you like, it helps discriminate ids + */ +export const getPageUniqueId = (prefix = "") => { + let id = prefix; + let limit = 99999; + while ( + typeof document !== "undefined" && + document.getElementById((id = `${prefix}${getReasonableUuid()}`)) && + limit-- > 0 + ); + return id; +}; + +export default getPageUniqueId \ No newline at end of file diff --git a/modules/utils/getReasonableUuid.mjs b/modules/utils/getReasonableUuid.mjs new file mode 100644 index 0000000..2dac6a3 --- /dev/null +++ b/modules/utils/getReasonableUuid.mjs @@ -0,0 +1,12 @@ +//@ts-check + +/** + * A reasonably unique id. + * Deterministic, it will always return the same id for the same call order + */ +export const getReasonableUuid = (() => { + let start = 100000000; + return () => (start++).toString(36); +})(); + +export default getReasonableUuid \ No newline at end of file diff --git a/modules/utils/index.mjs b/modules/utils/index.mjs index d75e474..73169a2 100644 --- a/modules/utils/index.mjs +++ b/modules/utils/index.mjs @@ -20,11 +20,14 @@ import { import { getElementByCSSSelector } from "./getElementByCSSSelector.mjs"; import { getElementById } from "./getElementById.mjs"; import { getFirstTitleContent } from "./getFirstTitleContent.mjs"; +import { getPageUniqueId } from "./getPageUniqueId.mjs"; +import { getReasonableUuid } from "./getReasonableUuid.mjs"; import { identity, awaitedIdentity } from "./identity.mjs"; import { html } from "./html.mjs"; import { isExternalUrl } from "./isExternalUrl.mjs"; import { isLocalHost } from "./isLocalHost.mjs"; import { isNotNull } from "./isNotNull.mjs"; +import { makeBoundConsole } from "./makeBoundConsole.mjs"; import { makeEventEmitter } from "./makeEventEmitter.mjs"; import { makeFileLoader, makeFileLoadersTracker } from "./makeFileLoader.mjs"; import { makeFileSizeFetcher } from "./makeFileSizeFetcher.mjs"; @@ -39,6 +42,14 @@ import { onDocumentKeyDown, onDocumentKey, } from "./onDocumentKey.mjs"; +import { + basename, + filename, + stripExtension, + dirName, + extension, + metadata, +} from "./path.mjs"; import { percentFromProgress } from "./percentFromProgress.mjs"; import { print, makeTemplate } from "./print.mjs"; import { @@ -71,12 +82,15 @@ export { getElementByCSSSelector, getElementById, getFirstTitleContent, + getPageUniqueId, + getReasonableUuid, html, identity, awaitedIdentity, isExternalUrl, isLocalHost, isNotNull, + makeBoundConsole, makeEventEmitter, makeFileLoader, makeFileLoadersTracker, @@ -90,6 +104,12 @@ export { onDocumentKeyUp, onDocumentKeyDown, onDocumentKey, + basename, + filename, + stripExtension, + dirName, + extension, + metadata, percentFromProgress, print, makeTemplate, diff --git a/modules/utils/makeBoundConsole.mjs b/modules/utils/makeBoundConsole.mjs new file mode 100644 index 0000000..d7e10ae --- /dev/null +++ b/modules/utils/makeBoundConsole.mjs @@ -0,0 +1,19 @@ +//@ts-check + +const methods = /** @type {('warn' & keyof typeof console)[]} */(["log", "warn", "error"]) + +/** + * Returns console methods that can be used standalone (without requiring `console`). + * Optional prefix will prepend every call with the provided prefix + * @param {any} prefix + */ +export const makeBoundConsole = (prefix = "") => { + const [log, warn, error] = methods.map( + (fn) => + (/** @type {any} */ msg) => + console[fn](prefix, msg) + ); + return { log, warn, error }; +}; + +export default makeBoundConsole \ No newline at end of file diff --git a/modules/utils/makeFileLoader.mjs b/modules/utils/makeFileLoader.mjs index 756f6a2..290a31b 100644 --- a/modules/utils/makeFileLoader.mjs +++ b/modules/utils/makeFileLoader.mjs @@ -3,8 +3,9 @@ import { createTrackedResponse } from "./createTrackedResponse.mjs"; import { decodeContentLength } from "./decodeContentLength.mjs"; import { retryPromise } from "./retryPromise.mjs"; -import { makeFileSizeFetcher } from "./makeFileSizeFetcher.mjs"; import { makeSignal } from "./makeSignal.mjs"; +import { fetchContentLength } from "./fetchContentLength.mjs"; +import { metadata } from "./path.mjs"; /** * @typedef {{ @@ -14,6 +15,11 @@ import { makeSignal } from "./makeSignal.mjs"; * }} FileLoaderOptions */ +/** + * @template T + * @typedef {import("./makeSignal.mjs").Signal} Signal + */ + /** * @typedef {ReturnType} FileLoader */ @@ -36,24 +42,46 @@ export const makeFileLoader = ( path, { total = 0, onProgress, signal } = {} ) => { - const progress = makeSignal({ received: 0, total: 0 }); + const progress = makeSignal({ path, received: 0, total: 0 }); + /** @type {Signal} */ + const done = makeSignal(); + /** @type {Signal} */ + const failed = makeSignal(); if (onProgress) { progress.connect(onProgress, { signal }); } - const createResponse = () => - createTrackedResponse( - (received) => progress.emit({ received, total }), + const createResponse = () => { + const response = createTrackedResponse( + (received) => progress.emit({ path, received, total }), signal ); + response.then(done.emit); + response.catch(failed.emit); + return response; + }; let responsePromise = createResponse(); /** * Retrieves the file size if `total` was not provided. */ - const fetchSize = makeFileSizeFetcher(path, total); + const fetchSize = (() => { + /** + * @type {Promise | null} + */ + let totalPromise; + + const fetchSize = () => + (totalPromise = total + ? Promise.resolve(total) + : fetchContentLength(path).then( + (fetchedTotal) => (total = fetchedTotal) + )); + + return fetchSize; + })(); /** * Fetches a file, dispatching progress events while doing so. @@ -102,9 +130,12 @@ export const makeFileLoader = ( }; return { + ...metadata(path), fetchSize, load, unload, + done, + failed, progress, get path() { return path; @@ -112,6 +143,9 @@ export const makeFileLoader = ( get total() { return total; }, + set total(newTotal) { + total = newTotal; + }, get received() { return responsePromise.received; }, @@ -145,14 +179,12 @@ export const makeFileLoadersTracker = (files) => { * Called every time any file's status changes. * Updates the total and received amounts. */ - const update = () => { + const update = (props) => { let _total = 0; let _received = 0; - - files.forEach((thing) => { - _total += thing.total; - _received += thing.received; - console.log(thing.path, thing.received, thing.total); + files.forEach((fileLoader) => { + _total += fileLoader.total; + _received += fileLoader.received; }); if (total != _total || received != _received) { total = _total; @@ -161,15 +193,30 @@ export const makeFileLoadersTracker = (files) => { } }; - files.forEach((loader) => { - loader.then(decrease); - loader.progress.connect(update); + files.forEach((fileLoader) => { + fileLoader.done.connect(decrease); + fileLoader.progress.connect(update); }); + /** + * Runs `fetchSize` on all files to ensure all have a size + */ const ensureSize = () => Promise.all(files.map(({ fetchSize }) => fetchSize())); - const loadAll = () => Promise.all(files.map(({ load }) => load())); + /** + * Loads all files. Loading happens once per file, so if it was called before, this is a + * no-op (for each particular file). + * If a file does not have a total set, then a small header fetch can optionally happen + * to fetch the initial size. + * @param {boolean} [andEnsureSize] set it to `false` to skip fetching sizes for files + * without a specified total + * @returns + */ + const loadAll = (andEnsureSize = true) => + (andEnsureSize ? ensureSize() : Promise.resolve()).then(() => + Promise.all(files.map(({ load }) => load())) + ); return { done, progress, ensureSize, loadAll }; }; diff --git a/modules/utils/makeSignal.mjs b/modules/utils/makeSignal.mjs index ea8a68d..731219a 100644 --- a/modules/utils/makeSignal.mjs +++ b/modules/utils/makeSignal.mjs @@ -7,6 +7,16 @@ /** * @typedef {{signal?: AbortSignal, once?: boolean}} ListenerOptions */ +/** + * @template T + * @typedef {{ + * connect(listener: Listener, options?: ListenerOptions): () => boolean, + * disconnect(listener: Listener): boolean, + * emit(args: T): void + * disable(): void + * }} Signal + */ + /** * Returns an event emitter for a specific signal @@ -14,7 +24,7 @@ * automatic typing where applicable. * @template T * @param {T} [_initial] - * @returns + * @returns {Signal} */ export const makeSignal = (_initial) => { /** @type {Set>} */ diff --git a/modules/utils/memoize.mjs b/modules/utils/memoize.mjs index 2fc84ce..ac85f4a 100644 --- a/modules/utils/memoize.mjs +++ b/modules/utils/memoize.mjs @@ -1,14 +1,19 @@ //@ts-check +const pickFirstArg = (fst, ..._none) => JSON.stringify(fst) + /** * Caches the result of a function. * The cache is available as `.cache` in case there's a need to clear anything. + * Uses the first parameter as a key by default, you can change this behavior by passing a custom + * hash function. * @template {(...args: any[]) => any} T * @param {T} functionToMemoize + * @param {(...args: Parameters) => string|number} [hashFunction] * @returns */ -export const memoize = (functionToMemoize) => { - /** @type {Map[0], ReturnType>} */ +export const memoize = (functionToMemoize, hashFunction = pickFirstArg) => { + /** @type {Map>} */ const cache = new Map() /** * @@ -16,15 +21,14 @@ export const memoize = (functionToMemoize) => { * @returns {ReturnType} */ const memoized = (...args) => { - const key = args[0] + const key = hashFunction(...args) if(!cache.has(key)){ cache.set(key, functionToMemoize(...args)) } - //@ts-ignore return cache.get(key) } - memoized.map = cache - return memoized + + return Object.assign(memoized, { cache }) } export default memoize \ No newline at end of file diff --git a/modules/utils/path.mjs b/modules/utils/path.mjs new file mode 100644 index 0000000..c83f09b --- /dev/null +++ b/modules/utils/path.mjs @@ -0,0 +1,40 @@ + +/** + * + * @param {string} str + * @param {string} [sep] defaults to `/` + */ +export const filename = (str, sep = '/') => str.slice(str.lastIndexOf(sep) + 1) + +/** + * @param {string} str + */ +export const stripExtension = (str) => str.slice(0,str.lastIndexOf('.')) + +/** + * + * @param {string} str + * @param {string} [sep] defaults to `/` + */ +export const basename = (str, sep = '/') => stripExtension(filename(str, sep)) + +/** + * @param {string} str + */ +export const extension = (str) => str.slice(str.lastIndexOf('.') + 1) + +/** + * @param {string} str + * @param {string} [sep] defaults to `/` + */ +export const dirName = (str, sep = '/') => str.slice(0, str.lastIndexOf(sep) + 1) + +export const metadata = (str, sep = '/') => ({ + basename: basename(str, sep), + filename: filename(str, sep), + extension: extension(str), + dirName: dirName(str), + fullPath: str +}) + +export default { basename, filename, stripExtension, dirName, extension, metadata } \ No newline at end of file