UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

445 lines (394 loc) 15.9 kB
'use strict'; var objChecker = require('./objChecker.cjs'); ///////////////////////////////////////////////////////////////// /** * Reads the contents of a file using the specified FileReader method. * * @param {File} file - The file to be read. * @param {'readAsArrayBuffer'|'readAsDataURL'|'readAsText'|'readAsBinaryString'} method - * The FileReader method to use for reading the file. * @returns {Promise<any>} - A promise that resolves with the file content, according to the chosen method. * @throws {Error} - If an unexpected error occurs while handling the result. * @throws {DOMException} - If the FileReader encounters an error while reading the file. */ function readFileBlob(file, method) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { try { resolve(reader.result); } catch (error) { reject(error); } }; reader.onerror = () => { reject(reader.error); }; reader[method](file); }); } /** * Reads a file as a Base64 string using FileReader, and optionally formats it as a full data URL. * * Performs strict validation to ensure the result is a valid Base64 string or a proper data URL. * * @param {File} file - The file to be read. * @param {boolean|string} [isDataUrl=false] - If true, returns a full data URL; if false, returns only the Base64 string; * if a string is passed, it is used as the MIME type in the data URL. * @returns {Promise<string>} - A promise that resolves with the Base64 string or data URL. * * @throws {TypeError} - If the result is not a string or if `isDataUrl` is not a valid type. * @throws {Error} - If the result does not match the expected data URL format or Base64 structure. * @throws {DOMException} - If the FileReader fails to read the file. */ function readBase64Blob(file, isDataUrl = false) { return new Promise((resolve, reject) => { if (typeof isDataUrl !== 'string' && typeof isDataUrl !== 'boolean') reject(new TypeError('The isDataUrl parameter must be a boolean or a string.')); readFileBlob(file, 'readAsDataURL') .then( /** * Ensure that the URL format is correct in the required pattern * @param {string} base64Data */ (base64Data) => { if (typeof base64Data !== 'string') throw new TypeError('Expected file content to be a string.'); const match = base64Data.match(/^data:(.+);base64,(.*)$/); if (!match || !match[2]) throw new Error('Invalid data URL format or missing Base64 content.'); const [, mimeType, base64] = match; if (!/^[\w/+]+=*$/.test(base64)) throw new Error('Base64 content is malformed.'); if (typeof isDataUrl === 'boolean') return resolve(isDataUrl ? base64Data : base64); if (!/^[\w-]+\/[\w.+-]+$/.test(isDataUrl)) throw new Error(`Invalid MIME type string: ${isDataUrl}`); return resolve(`data:${isDataUrl};base64,${base64}`); }, ) .catch(reject); }); } /** * Reads a file and strictly validates its content as proper JSON using FileReader. * * Performs several checks to ensure the file contains valid, parsable JSON data. * * @param {File} file - The file to be read. It must contain valid JSON as plain text. * @returns {Promise<Record<string|number|symbol, any>|any[]>} - A promise that resolves with the parsed JSON object. * * @throws {SyntaxError} - If the file content is not valid JSON syntax. * @throws {TypeError} - If the result is not a string or does not represent a JSON value. * @throws {Error} - If the result is empty or structurally invalid as JSON. * @throws {DOMException} - If the FileReader fails to read the file. */ function readJsonBlob(file) { return new Promise((resolve, reject) => readFileBlob(file, 'readAsText') .then((data) => { if (typeof data !== 'string') throw new TypeError('Expected file content to be a string.'); const trimmed = data.trim(); if (trimmed.length === 0) throw new Error('File is empty or contains only whitespace.'); const parsed = JSON.parse(trimmed); if (typeof parsed !== 'object' || parsed === null) throw new Error('Parsed content is not a valid JSON object or array.'); resolve(parsed); }) .catch(reject), ); } /** * Saves a JSON object as a downloadable file. * @param {string} filename * @param {any} data * @param {number} [spaces=2] */ function saveJsonFile(filename, data, spaces = 2) { const json = JSON.stringify(data, null, spaces); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } /** * @typedef {Object} FetchTemplateOptions * @property {string} [method="GET"] - HTTP method to use (GET, POST, etc.). * @property {any} [body] - Request body (only for methods like POST, PUT). * @property {number} [timeout=0] - Timeout in milliseconds (ignored if signal is provided). * @property {number} [retries=0] - Number of retry attempts (ignored if signal is provided). * @property {Headers|Record<string, *>} [headers={}] - Additional headers. * @property {AbortSignal|null} [signal] - External AbortSignal; disables timeout and retries. */ /** * @param {string} url - The full URL to fetch data from. * @param {FetchTemplateOptions} [options] - Optional settings. * @returns {Promise<Response>} Result data. * @throws {Error} Throws if fetch fails, times out. */ async function fetchTemplate( url, { method = 'GET', body, timeout = 0, retries = 0, headers = {}, signal = null } = {}, ) { if ( typeof url !== 'string' || (!url.startsWith('../') && !url.startsWith('./') && !url.startsWith('/') && !url.startsWith('https://') && !url.startsWith('http://')) ) throw new Error('Invalid URL: must be a valid http or https address.'); if (typeof method !== 'string' || !method.trim()) throw new Error('Invalid method: must be a non-empty string.'); if (!signal) { if ( typeof timeout !== 'number' || !Number.isFinite(timeout) || Number.isNaN(timeout) || timeout < 0 ) throw new Error('Invalid timeout: must be a positive number.'); if ( typeof retries !== 'number' || !Number.isFinite(retries) || Number.isNaN(retries) || retries < 0 ) throw new Error('Invalid retries: must be a positive number.'); } const attempts = signal ? 1 : retries + 1; /** @type {Error|null} */ let lastError = null; for (let attempt = 0; attempt < attempts; attempt++) { const controller = signal ? null : new AbortController(); const localSignal = signal || (controller?.signal ?? null); const timer = !signal && timeout && controller ? setTimeout(() => controller.abort(), timeout) : null; try { const response = await fetch(url, { method: method.toUpperCase(), headers: { Accept: 'application/json', ...headers, }, body: body !== undefined ? (objChecker.isJsonObject(body) ? JSON.stringify(body) : body) : undefined, signal: localSignal, }); if (timer) clearTimeout(timer); if (!response.ok) throw new Error(`HTTP error: ${response.status} ${response.statusText}`); return response; } catch (err) { lastError = /** @type {Error} */ (err); if (signal) break; // if an external signal came, it does not retry if (attempt < retries) await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))); } } throw new Error( `Failed to fetch JSON from "${url}"${lastError ? `: ${lastError.message}` : '.'}`, ); } /** * Loads and parses a JSON from a remote URL using Fetch API. * * @param {string} url - The full URL to fetch JSON from. * @param {FetchTemplateOptions} [options] - Optional settings. * @returns {Promise<any[] | Record<string | number | symbol, unknown>>} Parsed JSON object. * @throws {Error} Throws if fetch fails, times out, or returns invalid JSON. */ async function fetchJson(url, options) { return new Promise((resolve, reject) => { fetchTemplate(url, options) .then(async (res) => { const contentType = res.headers.get('content-type') || ''; if (!contentType.includes('application/json')) throw new Error(`Unexpected content-type: ${contentType}`); const data = await res.json(); if (!Array.isArray(data) && !objChecker.isJsonObject(data)) throw new Error('Received invalid data instead of valid JSON.'); return resolve(data); }) .catch(reject); }); } /** * Loads a remote file as a Blob using Fetch API. * * @param {string} url - The full URL to fetch the file from. * @param {FetchTemplateOptions} [options] - Optional fetch options. * @param {string[]} [allowedMimeTypes] - Optional list of accepted MIME types (e.g., ['image/jpeg']). * @returns {Promise<Blob>} - The fetched file as a Blob. * @throws {Error} Throws if fetch fails, response is not ok, or MIME type is not allowed. */ async function fetchBlob(url, allowedMimeTypes, options) { return new Promise((resolve, reject) => { fetchTemplate(url, options) .then(async (res) => { const contentType = res.headers.get('content-type') || ''; if ( Array.isArray(allowedMimeTypes) && allowedMimeTypes.length > 0 && !allowedMimeTypes.some((type) => contentType.includes(type)) ) { throw new Error(`Blocked MIME type: ${contentType}`); } const data = await res.blob(); return resolve(data); }) .catch(reject); }); } /** * Loads a remote file as a text using Fetch API. * * @param {string} url - The full URL to fetch the file from. * @param {FetchTemplateOptions} [options] - Optional fetch options. * @param {string[]} [allowedMimeTypes] - Optional list of accepted MIME types (e.g., ['image/jpeg']). * @returns {Promise<string>} - The fetched file as a text. * @throws {Error} Throws if fetch fails, response is not ok, or MIME type is not allowed. */ async function fetchText(url, allowedMimeTypes, options) { return new Promise((resolve, reject) => { fetchTemplate(url, options) .then(async (res) => { const contentType = res.headers.get('content-type') || ''; if ( Array.isArray(allowedMimeTypes) && allowedMimeTypes.length > 0 && !allowedMimeTypes.some((type) => contentType.includes(type)) ) { throw new Error(`Blocked MIME type: ${contentType}`); } const data = await res.text(); return resolve(data); }) .catch(reject); }); } /////////////////////////////////////////////////////////////////////////////// /** * Installs a script that toggles CSS classes on a given element * based on the page's visibility or focus state, and optionally * triggers callbacks on visibility changes. * * @param {Object} [settings={}] * @param {Element} [settings.element=document.body] - The element to receive visibility classes. * @param {string} [settings.hiddenClass='windowHidden'] - CSS class applied when the page is hidden. * @param {string} [settings.visibleClass='windowVisible'] - CSS class applied when the page is visible. * @param {(data: { type: string; oldType: string; oldClass: string; }) => void} [settings.onVisible] - Callback called when page becomes visible. * @param {(data: { type: string; oldType: string; oldClass: string; }) => void} [settings.onHidden] - Callback called when page becomes hidden. * @returns {() => void} Function that removes all installed event listeners. * @throws {TypeError} If any provided setting is invalid. */ function installWindowHiddenScript({ element = document.body, hiddenClass = 'windowHidden', visibleClass = 'windowVisible', onVisible, onHidden, } = {}) { if (!(element instanceof Element)) throw new TypeError(`"element" must be an instance of Element.`); if (typeof hiddenClass !== 'string') throw new TypeError(`"hiddenClass" must be a string.`); if (typeof visibleClass !== 'string') throw new TypeError(`"visibleClass" must be a string.`); if (onVisible !== undefined && typeof onVisible !== 'function') throw new TypeError(`"onVisible" must be a function if provided.`); if (onHidden !== undefined && typeof onHidden !== 'function') throw new TypeError(`"onHidden" must be a function if provided.`); let oldType = ''; let oldClass = ''; const removeClass = () => { element.classList.remove(hiddenClass); element.classList.remove(visibleClass); }; /** @type {string|null} */ let hiddenProp = null; const visibilityEvents = [ 'visibilitychange', 'mozvisibilitychange', 'webkitvisibilitychange', 'msvisibilitychange', ]; const visibilityProps = ['hidden', 'mozHidden', 'webkitHidden', 'msHidden']; for (let i = 0; i < visibilityProps.length; i++) { if (visibilityProps[i] in document) { hiddenProp = visibilityProps[i]; break; } } /** @type {(this: any, evt: Event) => void} */ const handler = function (evt) { removeClass(); const type = evt?.type; // @ts-ignore const isHidden = hiddenProp && document[hiddenProp]; const visibleEvents = ['focus', 'focusin', 'pageshow']; const hiddenEvents = ['blur', 'focusout', 'pagehide']; if (visibleEvents.includes(type)) { element.classList.add(visibleClass); onVisible?.({ type, oldClass, oldType }); oldClass = visibleClass; } else if (hiddenEvents.includes(type)) { element.classList.add(hiddenClass); onHidden?.({ type, oldClass, oldType }); oldClass = hiddenClass; } else { if (isHidden) { element.classList.add(hiddenClass); onHidden?.({ type, oldClass, oldType }); oldClass = hiddenClass; } else { element.classList.add(visibleClass); onVisible?.({ type, oldClass, oldType }); oldClass = visibleClass; } } oldType = type; }; /** @type {() => void} */ let uninstall = () => {}; if (hiddenProp) { const eventType = visibilityEvents[visibilityProps.indexOf(hiddenProp)]; document.addEventListener(eventType, handler); window.addEventListener('focus', handler); window.addEventListener('blur', handler); uninstall = () => { document.removeEventListener(eventType, handler); window.removeEventListener('focus', handler); window.removeEventListener('blur', handler); removeClass(); }; } else if ('onfocusin' in document) { // Fallback for IE9 and older // @ts-ignore document.onfocusin = document.onfocusout = handler; uninstall = () => { // @ts-ignore document.onfocusin = document.onfocusout = null; removeClass(); }; } else { // Last resort fallback window.onpageshow = window.onpagehide = window.onfocus = window.onblur = handler; uninstall = () => { window.onpageshow = window.onpagehide = window.onfocus = window.onblur = null; removeClass(); }; } // Trigger initial state // @ts-ignore const simulatedEvent = new Event(hiddenProp && document[hiddenProp] ? 'blur' : 'focus'); handler(simulatedEvent); return uninstall; } exports.fetchBlob = fetchBlob; exports.fetchJson = fetchJson; exports.fetchText = fetchText; exports.installWindowHiddenScript = installWindowHiddenScript; exports.readBase64Blob = readBase64Blob; exports.readFileBlob = readFileBlob; exports.readJsonBlob = readJsonBlob; exports.saveJsonFile = saveJsonFile;