/** * $.js - A minimalist 2025 utility library * Handwritten, under 300 lines, essential primitives only * https://assets.tools.ejfox.com/$.js */ // DOM Selection const $ = (sel, ctx = document) => ctx.querySelector(sel); const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; const $id = (id) => document.getElementById(id); // Ready const ready = (fn) => { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }; // Element creation const el = (tag, attrs = {}, children = []) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'style' && typeof v === 'object') Object.assign(e.style, v); else if (k === 'class') e.className = v; else if (k === 'data' && typeof v === 'object') { for (const [dk, dv] of Object.entries(v)) e.dataset[dk] = dv; } else if (k.startsWith('on') && typeof v === 'function') { e.addEventListener(k.slice(2).toLowerCase(), v); } else e.setAttribute(k, v); } for (const child of [].concat(children)) { if (child == null) continue; e.append(typeof child === 'string' ? child : child); } return e; }; // Event helpers const on = (el, evt, fn, opts) => { const target = typeof el === 'string' ? $(el) : el; if (target) target.addEventListener(evt, fn, opts); return () => target?.removeEventListener(evt, fn, opts); }; const once = (el, evt, fn) => on(el, evt, fn, { once: true }); const delegate = (parent, evt, sel, fn) => { const p = typeof parent === 'string' ? $(parent) : parent; if (!p) return; p.addEventListener(evt, (e) => { const target = e.target.closest(sel); if (target && p.contains(target)) fn(e, target); }); }; // Debounce & throttle const debounce = (fn, ms = 150) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; }; const throttle = (fn, ms = 150) => { let last = 0; return (...args) => { const now = Date.now(); if (now - last >= ms) { last = now; fn(...args); } }; }; // Class helpers const addClass = (el, ...classes) => el?.classList.add(...classes); const removeClass = (el, ...classes) => el?.classList.remove(...classes); const toggleClass = (el, cls, force) => el?.classList.toggle(cls, force); const hasClass = (el, cls) => el?.classList.contains(cls); // Show/hide const show = (el) => { if (el) el.style.display = ''; }; const hide = (el) => { if (el) el.style.display = 'none'; }; const toggle = (el) => { if (el) el.style.display = el.style.display === 'none' ? '' : 'none'; }; // Attribute helpers const attr = (el, key, val) => { if (val === undefined) return el?.getAttribute(key); el?.setAttribute(key, val); return el; }; const data = (el, key, val) => { if (val === undefined) return el?.dataset[key]; if (el) el.dataset[key] = val; return el; }; // Simple animations const fadeIn = (el, ms = 200) => { if (!el) return; el.style.opacity = 0; el.style.display = ''; el.style.transition = `opacity ${ms}ms`; requestAnimationFrame(() => { el.style.opacity = 1; }); }; const fadeOut = (el, ms = 200) => { if (!el) return; el.style.transition = `opacity ${ms}ms`; el.style.opacity = 0; setTimeout(() => { el.style.display = 'none'; }, ms); }; // Scroll const scrollTo = (target, opts = {}) => { const el = typeof target === 'string' ? $(target) : target; el?.scrollIntoView({ behavior: 'smooth', block: 'start', ...opts }); }; // Fetch helpers const get = async (url, opts = {}) => { const res = await fetch(url, opts); if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); const ct = res.headers.get('content-type') || ''; return ct.includes('json') ? res.json() : res.text(); }; const post = async (url, body, opts = {}) => { const isJson = typeof body === 'object' && !(body instanceof FormData); const res = await fetch(url, { method: 'POST', headers: isJson ? { 'Content-Type': 'application/json' } : {}, body: isJson ? JSON.stringify(body) : body, ...opts }); if (!res.ok) throw new Error(`POST ${url}: ${res.status}`); const ct = res.headers.get('content-type') || ''; return ct.includes('json') ? res.json() : res.text(); }; // Storage const store = { get: (key, fallback = null) => { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch { return fallback; } }, set: (key, val) => { try { localStorage.setItem(key, JSON.stringify(val)); } catch {} }, del: (key) => localStorage.removeItem(key), clear: () => localStorage.clear() }; // URL helpers const params = () => Object.fromEntries(new URLSearchParams(location.search)); const setParams = (obj) => { const p = new URLSearchParams(location.search); for (const [k, v] of Object.entries(obj)) { if (v == null || v === '') p.delete(k); else p.set(k, v); } history.replaceState(null, '', `${location.pathname}?${p}`); }; // Simple state (reactive-ish) const signal = (initial) => { let value = initial; const subs = new Set(); return { get: () => value, set: (v) => { value = typeof v === 'function' ? v(value) : v; subs.forEach(fn => fn(value)); }, sub: (fn) => { subs.add(fn); return () => subs.delete(fn); } }; }; // Template helper (tagged template) const html = (strings, ...vals) => { const tpl = document.createElement('template'); tpl.innerHTML = strings.reduce((acc, s, i) => { const v = vals[i] ?? ''; return acc + s + (typeof v === 'object' ? '' : String(v)); }, '').trim(); return tpl.content.cloneNode(true); }; // Intersection observer helper const observe = (el, fn, opts = {}) => { const target = typeof el === 'string' ? $(el) : el; if (!target) return; const io = new IntersectionObserver((entries) => { entries.forEach(e => fn(e.isIntersecting, e)); }, { threshold: 0.1, ...opts }); io.observe(target); return () => io.disconnect(); }; // Keyboard shortcuts const keys = {}; const hotkey = (combo, fn) => { keys[combo.toLowerCase()] = fn; }; document.addEventListener('keydown', (e) => { if (e.target.matches('input, textarea, select, [contenteditable]')) return; const parts = []; if (e.ctrlKey || e.metaKey) parts.push('mod'); if (e.shiftKey) parts.push('shift'); if (e.altKey) parts.push('alt'); parts.push(e.key.toLowerCase()); const combo = parts.join('+'); if (keys[combo]) { e.preventDefault(); keys[combo](e); } }); // Copy to clipboard const copy = async (text) => { try { await navigator.clipboard.writeText(text); return true; } catch { return false; } }; // Format helpers // Note: For d3.format, import from https://cdn.jsdelivr.net/npm/d3-format@3/+esm // These are lightweight wrappers; apps should import d3-format directly for full power const fmt = { date: (d, opts = {}) => new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', ...opts }).format(new Date(d)), time: (d) => new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).format(new Date(d)), num: (n, opts = {}) => new Intl.NumberFormat('en-US', opts).format(n), currency: (n, cur = 'USD') => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(n), // Percentage with optional decimals pct: (n, decimals = 0) => { if (n === null || n === undefined) return '–'; return n.toFixed(decimals) + '%'; }, bytes: (b) => { const units = ['B', 'KB', 'MB', 'GB']; let i = 0; while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; } return `${b.toFixed(1)} ${units[i]}`; }, ago: (d) => { const s = Math.floor((Date.now() - new Date(d)) / 1000); if (s < 60) return 'just now'; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; if (s < 604800) return `${Math.floor(s / 86400)}d ago`; return fmt.date(d); } }; // Random helpers const rand = { int: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min, pick: (arr) => arr[Math.floor(Math.random() * arr.length)], shuffle: (arr) => [...arr].sort(() => Math.random() - 0.5), id: () => Math.random().toString(36).slice(2, 11) }; // ===== GWERN-INSPIRED UTILITIES ===== // Array extensions (from gwern's utility.js) Object.defineProperties(Array.prototype, { first: { get() { return this[0]; } }, last: { get() { return this[this.length - 1]; } }, unique: { value() { return [...new Set(this)]; } }, removeIf: { value(test) { const i = this.findIndex(test); if (i >= 0) return this.splice(i, 1)[0]; } } }); // String extensions Object.defineProperties(String.prototype, { capitalizeWords: { value() { return this.replace(/\b\w/g, c => c.toUpperCase()); } }, includesAnyOf: { value(substrs) { return substrs.some(s => this.includes(s)); } }, kebabToCamel: { value() { return this.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } }, camelToKebab: { value() { return this.replace(/[A-Z]/g, c => '-' + c.toLowerCase()); } } }); // Clamp value between min and max const clamp = (val, min, max) => Math.min(Math.max(val, min), max); // True modulo (handles negative numbers correctly) const mod = (n, d) => ((n % d) + d) % d; // Levenshtein distance (bounded, from gwern's 404-guesser.js) const levenshtein = (a, b, maxDist = Infinity) => { if (Math.abs(a.length - b.length) > maxDist) return maxDist + 1; const matrix = Array.from({ length: b.length + 1 }, (_, i) => [i]); for (let j = 1; j <= a.length; j++) matrix[0][j] = j; for (let i = 1; i <= b.length; i++) { let minDist = maxDist + 1; for (let j = 1; j <= a.length; j++) { matrix[i][j] = b[i-1] === a[j-1] ? matrix[i-1][j-1] : Math.min(matrix[i-1][j-1] + 1, matrix[i][j-1] + 1, matrix[i-1][j] + 1); minDist = Math.min(minDist, matrix[i][j]); } if (minDist > maxDist) return maxDist + 1; } return matrix[b.length][a.length]; }; // Jaccard similarity (from gwern's utility.js) const jaccard = (a, b) => { const setA = new Set(a.toLowerCase().split(/\W+/)); const setB = new Set(b.toLowerCase().split(/\W+/)); const intersection = [...setA].filter(x => setB.has(x)).length; const union = new Set([...setA, ...setB]).size; return union === 0 ? 0 : intersection / union; }; // Find similar strings (for 404 suggestions, search, etc.) const findSimilar = (needle, haystack, maxResults = 5, maxDist = 8) => { return haystack .map(s => ({ value: s, dist: levenshtein(s, needle, maxDist) })) .filter(x => x.dist <= maxDist) .sort((a, b) => a.dist - b.dist) .slice(0, maxResults) .map(x => x.value); }; // Smart typography (from gwern's typography.js) const smartQuotes = (text) => { return text .replace(/"([^"]*)"/g, "\u201C$1\u201D") // Double quotes .replace(/'([^']*)'/g, "\u2018$1\u2019") // Single quotes .replace(/(\s)'(\w)/g, "$1\u2018$2") // Opening single .replace(/(\w)'/g, "$1\u2019") // Closing single/apostrophe .replace(/--/g, '—') // Em dash .replace(/ - /g, ' – ') // En dash .replace(/\.\.\./g, '…') // Ellipsis .replace(/->/g, '→') // Arrow .replace(/<-/g, '←') // Arrow .replace(/=>/g, '⇒') // Double arrow .replace(/<=/g, '⇐') // Double arrow .replace(/\(c\)/gi, '©') // Copyright .replace(/\(r\)/gi, '®') // Registered .replace(/\(tm\)/gi, '™'); // Trademark }; // Wrap element (from gwern's utility.js) const wrap = (el, wrapperSpec) => { const [tag, ...classes] = wrapperSpec.split('.'); const wrapper = document.createElement(tag || 'div'); if (classes.length) wrapper.className = classes.join(' '); el.parentNode.insertBefore(wrapper, el); wrapper.appendChild(el); return wrapper; }; // Unwrap element const unwrap = (wrapper) => { const parent = wrapper.parentNode; while (wrapper.firstChild) parent.insertBefore(wrapper.firstChild, wrapper); parent.removeChild(wrapper); }; // Lazy load observer (from gwern's utility.js) const lazyLoad = (el, callback, opts = {}) => { const target = typeof el === 'string' ? $(el) : el; if (!target) return; const io = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { callback(entry.target); observer.unobserve(entry.target); } }); }, { rootMargin: '50px', ...opts }); io.observe(target); return () => io.disconnect(); }; // Dark mode toggle (inspired by gwern's dark-mode.js) const darkMode = { get: () => store.get('darkMode', 'auto'), set: (mode) => { store.set('darkMode', mode); darkMode.apply(); }, apply: () => { const mode = darkMode.get(); const root = document.documentElement; root.classList.remove('light', 'dark'); if (mode === 'auto') { // Follow system preference if (window.matchMedia('(prefers-color-scheme: dark)').matches) { root.classList.add('dark'); } } else { root.classList.add(mode); } }, toggle: () => { const modes = ['light', 'dark', 'auto']; const current = darkMode.get(); const next = modes[(modes.indexOf(current) + 1) % modes.length]; darkMode.set(next); return next; } }; // Initialize dark mode on load if (typeof window !== 'undefined') { darkMode.apply(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', darkMode.apply); } // Map style URL (auto dark/light based on preference) const mapStyle = () => { const isDark = darkMode.get() === 'dark' || (darkMode.get() === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); return `https://assets.tools.ejfox.com/vulpes-${isDark ? 'dark' : 'light'}.json`; }; // Export for module use if (typeof module !== 'undefined') { module.exports = { $, $$, $id, ready, el, on, once, delegate, debounce, throttle, addClass, removeClass, toggleClass, hasClass, show, hide, toggle, attr, data, fadeIn, fadeOut, scrollTo, get, post, store, params, setParams, signal, html, observe, hotkey, copy, fmt, rand, clamp, mod, levenshtein, jaccard, findSimilar, smartQuotes, wrap, unwrap, lazyLoad, darkMode }; }