UNPKG

@gmana/utils

Version:

Utility functions for React and TypeScript projects.

1 lines β€’ 85.4 kB
{"version":3,"file":"index.cjs","names":[],"sources":["../src/join-paths.ts","../src/absolute-url.ts","../src/bytes.ts","../src/chunk.ts","../src/clsx.ts","../src/cn.ts","../src/compact.ts","../src/compact-object.ts","../src/count-by.ts","../src/difference.ts","../src/drop.ts","../src/drop-right.ts","../src/flatten.ts","../src/flatten-array.ts","../src/flatten-deep-array.ts","../src/format-time.ts","../src/get-initial-letter.ts","../src/get-os.ts","../src/get-token-exp-claim.ts","../src/group-by.ts","../src/group-consecutive.ts","../src/unique.ts","../src/intersection.ts","../src/is-array.ts","../src/is-boolean.ts","../src/is-dev.ts","../src/is-object.ts","../src/is-empty.ts","../src/is-function.ts","../src/is-navigator.ts","../src/is-number.ts","../src/is-string.ts","../src/is-symbol.ts","../src/is-token-expired.ts","../src/is-undef.ts","../src/is-url.ts","../src/is-valid-component-name.ts","../src/is-valid-json-string.ts","../src/is-valid-url.ts","../src/make-title.ts","../src/max-by.ts","../src/sum-by.ts","../src/mean-by.ts","../src/min-by.ts","../src/number.ts","../src/partition.ts","../src/pick.ts","../src/shuffle.ts","../src/sample.ts","../src/sort-by.ts","../src/symmetric-difference.ts","../src/take.ts","../src/take-right.ts","../src/to-case.ts","../src/to-iso.ts","../src/truncate-text.ts","../src/unique-by.ts","../src/v-card.ts","../src/zip.ts"],"sourcesContent":["/**\n * Joins multiple URL path segments together.\n *\n * @param segments - Array of path segments to join.\n * @returns The joined path string.\n *\n * @example\n * ```typescript\n * joinPaths([\"api\", \"users\", \"123\"]) // \"/api/users/123\"\n * joinPaths([\"\", \"api\", \"users\"]) // \"/api/users\"\n * ```\n */\nexport function joinPaths(segments: (string | null | undefined)[]): string {\n const joined = segments\n .filter((s): s is string => Boolean(s))\n .map((s) => s.replace(/^\\/+|\\/+$/g, \"\"))\n .filter(Boolean)\n .join(\"/\")\n\n return `/${joined}`\n}\n","import { joinPaths } from \"./join-paths\"\n\n/**\n * Creates an absolute URL by combining a base URL with a relative path.\n *\n * @param path - The relative path to append to the base URL. Can be null, undefined, or empty.\n * @param options - Optional configuration for URL generation.\n * @returns The absolute URL string.\n *\n * @example\n * ```typescript\n * absoluteUrl(\"/api/users\") // \"https://example.com/api/users\"\n * absoluteUrl(\"api/users\") // \"https://example.com/api/users\"\n * absoluteUrl(\"\") // \"https://example.com\"\n * absoluteUrl(null) // \"https://example.com\"\n * absoluteUrl(\"/api/users\", { query: { id: \"123\" } }) // \"https://example.com/api/users?id=123\"\n * absoluteUrl(\"/api/users\", { fragment: \"section1\" }) // \"https://example.com/api/users#section1\"\n * ```\n */\nexport function absoluteUrl(\n path?: string | null,\n options?: {\n query?: Record<string, string | number | boolean | null | undefined>\n fragment?: string\n baseUrl?: string\n }\n): string {\n const rawBase = options?.baseUrl ?? process.env.NEXT_PUBLIC_APP_URL ?? \"http://localhost:3000\"\n\n let url: URL\n try {\n url = new URL(rawBase)\n } catch {\n throw new Error(`Invalid base URL: ${rawBase}`)\n }\n\n if (path?.trim()) {\n url.pathname = joinPaths([url.pathname, path])\n }\n\n if (options?.query) {\n for (const [key, value] of Object.entries(options.query)) {\n if (value != null) {\n url.searchParams.append(key, String(value))\n }\n }\n }\n\n if (options?.fragment) {\n // Assigning to .hash handles encoding correctly; don't prepend '#'\n url.hash = options.fragment\n }\n\n return url.toString()\n}\n","export type ByteUnit = \"b\" | \"kb\" | \"mb\" | \"gb\" | \"tb\" | \"pb\" | \"k\" | \"m\" | \"g\" | \"t\" | \"p\"\nexport type ByteInput = `${number}${ByteUnit}` | `${number}` | number\nexport type ByteBase = 1000 | 1024\n\nexport interface ByteConvertOptions {\n /** Base for calculations: 1024 (binary) or 1000 (decimal). Default: 1024 */\n base: ByteBase\n /** Whether to round the result to the nearest integer. Default: true */\n round: boolean\n}\n\nexport interface FormatBytesOptions {\n base?: ByteBase\n precision?: number\n /** Use full unit names e.g. \"bytes\", not \"B\". Default: false */\n verbose?: boolean\n}\n\nexport const BYTE_REGEX = /^([+-]?(?:[0-9]*\\.?[0-9]+(?:[eE][+-]?[0-9]+)?|Infinity))\\s*([a-zA-Z]*)$/i\n\ntype UnitMultiplierEntry = 1 | ((base: ByteBase) => number)\n\nconst UNIT_MULTIPLIERS: Record<string, UnitMultiplierEntry> = {\n \"\": 1,\n b: 1,\n k: (base) => base,\n kb: (base) => base,\n m: (base) => base ** 2,\n mb: (base) => base ** 2,\n g: (base) => base ** 3,\n gb: (base) => base ** 3,\n t: (base) => base ** 4,\n tb: (base) => base ** 4,\n p: (base) => base ** 5,\n pb: (base) => base ** 5,\n} as const\n\nconst SUPPORTED_UNITS = Object.keys(UNIT_MULTIPLIERS)\n .filter((u) => u !== \"\")\n .join(\", \")\n\n/**\n * Converts a byte string or number to raw bytes.\n *\n * @example\n * toBytes(\"1kb\") // 1024\n * toBytes(\"1mb\", { base: 1000 }) // 1000000\n * toBytes(\"2.5gb\") // 2684354560\n * toBytes(1024) // 1024\n */\nexport function toBytes(input: ByteInput, options: Partial<ByteConvertOptions> = {}): number {\n const { base = 1024, round = true } = options\n\n const normalizedInput = String(input).toLowerCase().trim()\n const match = normalizedInput.match(BYTE_REGEX)\n\n if (!match) {\n throw new Error(`Invalid input: \"${input}\". Expected a number or formatted string like \"2.5kb\", \"33mb\"`)\n }\n\n const value = parseFloat(match[1])\n const unit = match[2] as keyof typeof UNIT_MULTIPLIERS\n\n if (!Number.isFinite(value)) {\n throw new Error(`Non-finite values are not supported: \"${match[1]}\"`)\n }\n\n if (value < 0) {\n throw new Error(`Negative values are not supported: \"${value}\"`)\n }\n\n const multiplierEntry = UNIT_MULTIPLIERS[unit]\n\n if (multiplierEntry === undefined) {\n throw new Error(`Unsupported unit: \"${unit}\". Supported units: ${SUPPORTED_UNITS}`)\n }\n\n const multiplier = typeof multiplierEntry === \"function\" ? multiplierEntry(base) : multiplierEntry\n const result = value * multiplier\n\n return round ? Math.round(result) : result\n}\n\nconst SHORT_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const\nconst LONG_UNITS = [\"bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const\n\n/**\n * Formats a raw byte count into a human-readable string.\n *\n * @example\n * formatBytes(1536) // \"1.50 KB\"\n * formatBytes(1500, { base: 1000 }) // \"1.50 KB\"\n * formatBytes(1024, { verbose: true }) // \"1.00 KB\"\n * formatBytes(0) // \"0 B\"\n */\nexport function formatBytes(bytes: number, options: FormatBytesOptions = {}): string {\n const { base = 1024, precision = 2, verbose = false } = options\n const units = verbose ? LONG_UNITS : SHORT_UNITS\n\n if (bytes < 0) throw new Error(`Negative values are not supported: \"${bytes}\"`)\n if (bytes === 0) return `0 ${units[0]}`\n if (bytes < 1) return `${bytes.toFixed(precision)} ${units[0]}`\n\n const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1)\n\n const value = bytes / Math.pow(base, unitIndex)\n return `${value.toFixed(precision)} ${units[unitIndex]}`\n}\n\n/** Creates a pre-configured `toBytes` converter with fixed defaults. */\nexport function createByteConverter(defaultOptions: Partial<ByteConvertOptions> = {}) {\n return (input: ByteInput, overrides?: Partial<ByteConvertOptions>): number =>\n toBytes(input, { ...defaultOptions, ...overrides })\n}\n","export const chunk = <T>(array: T[], size: number): T[][] => {\n if (size <= 0) throw new Error(\"Chunk size must be greater than 0\")\n if (array.length === 0) return []\n return Array.from({ length: Math.ceil(array.length / size) }, (_, i) => {\n const start = i * size\n return array.slice(start, start + size)\n })\n}\n","export type ClassValue = string | number | ClassRecord | ClassArray | undefined | null | boolean\n\nexport interface ClassRecord {\n [key: string]: boolean | undefined | null\n}\n\nexport type ClassArray = Array<ClassValue>\n\nfunction resolveValue(value: ClassValue): string {\n if (typeof value === \"string\") return value\n\n if (typeof value === \"number\") return String(value) || \"\"\n\n if (Array.isArray(value)) {\n return value.map(resolveValue).filter(Boolean).join(\" \")\n }\n\n if (value !== null && typeof value === \"object\") {\n return Object.entries(value)\n .filter(([, v]) => Boolean(v))\n .map(([k]) => k)\n .join(\" \")\n }\n\n return \"\"\n}\n\n/**\n * Joins class names together.\n *\n * Accepts strings, numbers, arrays, or objects mapping keys to booleans.\n * Falsy values (false, null, undefined) are ignored.\n *\n * @example\n * clsx(\"foo\", \"bar\") // \"foo bar\"\n * clsx(\"foo\", { bar: true, baz: false }) // \"foo bar\"\n * clsx([\"foo\", null, \"bar\"]) // \"foo bar\"\n * clsx(\"foo\", undefined, \"bar\") // \"foo bar\"\n */\nexport function clsx(...args: ClassValue[]): string {\n return args.map(resolveValue).filter(Boolean).join(\" \")\n}\n","import { twMerge } from \"tailwind-merge\"\nimport { clsx, type ClassValue } from \"./clsx\"\n\nexport const cn = (...inputs: ClassValue[]) => {\n return twMerge(clsx(...inputs))\n}\n","export const compact = <T>(array: (T | null | undefined | false | 0 | \"\")[]): T[] => {\n return array.filter(Boolean) as T[]\n}\n","type JSONPrimitive = string | number | boolean | null\ntype JSONValue = JSONPrimitive | JSONObject | JSONArray\ninterface JSONObject {\n [key: string]: JSONValue\n}\ntype JSONArray = JSONValue[]\n\ninterface CompactOptions {\n compactArrays?: boolean\n removeEmptyArrays?: boolean\n isEmpty?: (value: unknown) => boolean\n}\n\nconst defaultIsEmpty = (value: unknown): boolean => value === \"\" || value === null || value === undefined\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return Object.prototype.toString.call(value) === \"[object Object]\"\n}\n\nexport function compactObject<T extends Record<string, unknown>>(input: T, options: CompactOptions = {}): Partial<T> {\n const { compactArrays = true, removeEmptyArrays = false, isEmpty = defaultIsEmpty } = options\n\n const result: Record<string, unknown> = {}\n\n for (const [key, value] of Object.entries(input)) {\n if (isEmpty(value)) continue\n\n // Array handling\n if (Array.isArray(value)) {\n if (!compactArrays) {\n result[key] = value\n continue\n }\n\n const next = value\n .map((item) => {\n if (isPlainObject(item)) {\n const compacted = compactObject(item, options)\n return Object.keys(compacted).length > 0 ? compacted : undefined\n }\n\n return isEmpty(item) ? undefined : item\n })\n .filter((v) => v !== undefined)\n\n if (!removeEmptyArrays || next.length > 0) {\n result[key] = next\n }\n\n continue\n }\n\n // Object handling\n if (isPlainObject(value)) {\n const compacted = compactObject(value, options)\n\n if (Object.keys(compacted).length > 0) {\n result[key] = compacted\n }\n\n continue\n }\n\n // Primitive\n result[key] = value\n }\n\n return result as Partial<T>\n}\n","/**\n * Counts occurrences of each element in an array\n */\nexport const countBy = <T, K extends string | number | symbol>(\n array: T[],\n keyFn: (item: T) => K\n): Record<K, number> => {\n return array.reduce(\n (counts, item) => {\n const key = keyFn(item)\n counts[key] = (counts[key] || 0) + 1\n return counts\n },\n {} as Record<K, number>\n )\n}\n","export const difference = <T>(array1: T[], array2: T[]): T[] => {\n const set2 = new Set(array2)\n return array1.filter((item) => !set2.has(item))\n}\n","/**\n * Drops n elements from the beginning of an array\n */\nexport const drop = <T>(array: T[], n: number): T[] => {\n return array.slice(Math.max(0, n))\n}\n","/**\n * Drops elements from the end of an array\n */\nexport const dropRight = <T>(array: T[], n: number): T[] => {\n return n === 0 ? [...array] : array.slice(0, -n)\n}\n","/**\n * Flattens a nested object or array into a single-level object.\n * @param obj The object or array to flatten.\n * @param separator The string to use between nested keys.\n * @returns A flattened object.\n */\nexport const flatten = (obj: Record<string, unknown>, separator = \"-\"): Record<string, unknown> => {\n const flattened: Record<string, unknown> = {}\n\n // The recursive function now accepts `unknown` for type safety.\n const recurse = (current: unknown, path: string) => {\n // This type guard is crucial. We must check if `current` is a\n // non-null object before trying to iterate over its entries.\n if (typeof current !== \"object\" || current === null) {\n return\n }\n\n // After the check, TypeScript knows `current` is an object (or array),\n // so `Object.entries` can be used safely.\n for (const [key, value] of Object.entries(current)) {\n const newPath = path ? `${path}${separator}${key}` : key\n\n // Recurse if the value is another non-null object.\n if (typeof value === \"object\" && value !== null) {\n recurse(value, newPath)\n } else {\n flattened[newPath] = value\n }\n }\n }\n\n recurse(obj, \"\")\n return flattened\n}\n","export const flattenArray = <T>(array: (T | T[])[]): T[] => {\n return array.reduce<T[]>((acc, val) => (Array.isArray(val) ? acc.concat(val) : acc.concat([val])), [])\n}\n","export const flattenDeepArray = <T>(array: (T | (T | T[])[])[]): T[] => {\n return array.reduce<T[]>(\n (acc, val) =>\n Array.isArray(val) ? acc.concat(flattenDeepArray(val as (T | (T | T[])[])[])) : acc.concat(val as T),\n []\n )\n}\n","export interface FormatTimeOptions {\n /**\n * Format style for the time display\n * - 'digital': \"1:05:30\" or \"5:30\"\n * - 'long': \"1 hour 5 minutes 30 seconds\"\n * - 'short': \"1h 5m 30s\"\n * - 'compact': \"1:05:30\" (always shows hours)\n * @default 'digital'\n */\n format?: \"digital\" | \"long\" | \"short\" | \"compact\"\n\n /**\n * Always show hours even if 0\n * @default false\n */\n alwaysShowHours?: boolean\n\n /**\n * Round decimal seconds\n * - 'floor': Round down (default)\n * - 'ceil': Round up\n * - 'round': Round to nearest\n * @default 'floor'\n */\n roundingMode?: \"floor\" | \"ceil\" | \"round\"\n\n /**\n * Show leading zero for minutes when hours are present\n * @default true\n */\n padMinutes?: boolean\n\n /**\n * Custom separator for digital format\n * @default ':'\n */\n separator?: string\n}\n\n/**\n * Formats seconds into a human-readable time string\n *\n * @param seconds - The number of seconds to format (must be >= 0)\n * @param options - Formatting options\n * @returns Formatted time string\n *\n * @example\n * ```typescript\n * formatTime(65) // \"1:05\"\n * formatTime(3665) // \"1:01:05\"\n * formatTime(65, { format: 'short' }) // \"1m 5s\"\n * formatTime(65, { format: 'long' }) // \"1 minute 5 seconds\"\n * formatTime(5, { alwaysShowHours: true }) // \"0:00:05\"\n * formatTime(5.7, { roundingMode: 'ceil' }) // \"0:06\"\n * formatTime(65, { separator: 'Β·' }) // \"1Β·05\"\n * ```\n */\nexport function formatTime(seconds: number, options: FormatTimeOptions = {}): string {\n const {\n format = \"digital\",\n alwaysShowHours = false,\n roundingMode = \"floor\",\n padMinutes = true,\n separator = \":\",\n } = options\n\n // Validate input\n if (!Number.isFinite(seconds)) {\n throw new Error(\"formatTime: seconds must be a finite number\")\n }\n\n if (seconds < 0) {\n throw new Error(\"formatTime: seconds must be non-negative\")\n }\n\n // Round seconds based on mode\n const roundedSeconds =\n roundingMode === \"ceil\" ? Math.ceil(seconds) : roundingMode === \"round\" ? Math.round(seconds) : Math.floor(seconds)\n\n // Calculate time components\n const hours = Math.floor(roundedSeconds / 3600)\n const minutes = Math.floor((roundedSeconds % 3600) / 60)\n const secs = roundedSeconds % 60\n\n const hasHours = hours > 0 || alwaysShowHours\n\n // Format based on style\n switch (format) {\n case \"long\":\n return formatLong(hours, minutes, secs, hasHours)\n\n case \"short\":\n return formatShort(hours, minutes, secs, hasHours)\n\n case \"compact\":\n return formatDigital(hours, minutes, secs, true, padMinutes, separator)\n\n case \"digital\":\n default:\n return formatDigital(hours, minutes, secs, hasHours, padMinutes, separator)\n }\n}\n\n/**\n * Helper: Format in digital style (1:05:30 or 5:30)\n */\nfunction formatDigital(\n hours: number,\n minutes: number,\n seconds: number,\n showHours: boolean,\n padMinutes: boolean,\n separator: string\n): string {\n const parts: string[] = []\n\n if (showHours) {\n parts.push(hours.toString())\n parts.push(minutes.toString().padStart(2, \"0\"))\n } else {\n parts.push(padMinutes ? minutes.toString().padStart(2, \"0\") : minutes.toString())\n }\n\n parts.push(seconds.toString().padStart(2, \"0\"))\n\n return parts.join(separator)\n}\n\n/**\n * Helper: Format in long style (1 hour 5 minutes 30 seconds)\n */\nfunction formatLong(hours: number, minutes: number, seconds: number, showHours: boolean): string {\n const parts: string[] = []\n\n if (showHours && hours > 0) {\n parts.push(`${hours} ${pluralize(\"hour\", hours)}`)\n }\n\n if (minutes > 0) {\n parts.push(`${minutes} ${pluralize(\"minute\", minutes)}`)\n }\n\n if (seconds > 0 || parts.length === 0) {\n parts.push(`${seconds} ${pluralize(\"second\", seconds)}`)\n }\n\n return parts.join(\" \")\n}\n\n/**\n * Helper: Format in short style (1h 5m 30s)\n */\nfunction formatShort(hours: number, minutes: number, seconds: number, showHours: boolean): string {\n const parts: string[] = []\n\n if (showHours && hours > 0) {\n parts.push(`${hours}h`)\n }\n\n if (minutes > 0) {\n parts.push(`${minutes}m`)\n }\n\n if (seconds > 0 || parts.length === 0) {\n parts.push(`${seconds}s`)\n }\n\n return parts.join(\" \")\n}\n\n/**\n * Helper: Pluralize words\n */\nfunction pluralize(word: string, count: number): string {\n return count === 1 ? word : `${word}s`\n}\n\n/**\n * Parses a formatted time string back to seconds\n *\n * @param timeString - Time string in format \"1:05:30\", \"1:05\", \"5:30\", etc.\n * @param separator - Separator used in the time string\n * @returns Number of seconds\n *\n * @example\n * ```typescript\n * parseTime(\"1:05\") // 65\n * parseTime(\"1:01:05\") // 3665\n * parseTime(\"5:30\") // 330\n * ```\n */\nexport function parseTime(timeString: string, separator: string = \":\"): number {\n if (!timeString || typeof timeString !== \"string\") {\n throw new Error(\"parseTime: timeString must be a non-empty string\")\n }\n\n const parts = timeString.split(separator).map((part) => {\n const num = parseInt(part, 10)\n if (!Number.isFinite(num) || num < 0) {\n throw new Error(`parseTime: invalid time component \"${part}\"`)\n }\n return num\n })\n\n if (parts.length === 0 || parts.length > 3) {\n throw new Error(\"parseTime: timeString must have 1-3 components (SS, MM:SS, or HH:MM:SS)\")\n }\n\n // Handle different formats\n if (parts.length === 1) {\n // Just seconds\n return parts[0]!\n } else if (parts.length === 2) {\n // MM:SS\n return parts[0]! * 60 + parts[1]!\n } else {\n // HH:MM:SS\n return parts[0]! * 3600 + parts[1]! * 60 + parts[2]!\n }\n}\n","/**\n * Generates a one or two-letter initial string from a full name.\n * It takes the first letter of the first two name parts.\n * This function correctly handles diacritics, extra whitespace, and empty or invalid inputs.\n *\n * @example\n * getInitialLetter(\"John Doe\") // \"JD\"\n * getInitialLetter(\" Beyond Knowles-Carter\") // \"BK\"\n * getInitialLetter(\"Cher\") // \"C\"\n * getInitialLetter(null) // \"?\"\n *\n * @param fullName The full name to process. Can be a string, null, or undefined.\n * @returns The uppercase initials (1 or 2 characters), or an empty string if the input is invalid.\n */\nexport function getInitialLetter(fullName?: string | null, fallback: string = \"?\"): string {\n const cleanedName = fullName?.trim() ?? \"\"\n if (!cleanedName) {\n return fallback\n }\n\n return cleanedName\n .split(/\\s+/)\n .slice(0, 2)\n .map(\n (part) =>\n part[0]\n ?.normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .toUpperCase() ?? \"\"\n )\n .join(\"\")\n}\n","export type OS = \"windows\" | \"macos\" | \"linux\" | \"android\" | \"ios\" | \"unknown\"\n\nexport const osMap: Record<OS, { type: OS; label: string }> = {\n windows: {\n type: \"windows\",\n label: \"Windows\",\n },\n macos: {\n type: \"macos\",\n label: \"macOS\",\n },\n linux: {\n type: \"linux\",\n label: \"Linux\",\n },\n android: {\n type: \"android\",\n label: \"Android\",\n },\n ios: {\n type: \"ios\",\n label: \"iOS\",\n },\n unknown: {\n type: \"unknown\",\n label: \"Unknown\",\n },\n}\n\nexport function getOS(userAgent: string): { type: OS; label: string } {\n const agent = userAgent.toLowerCase()\n\n if (agent.includes(\"win\")) {\n return osMap.windows\n } else if (agent.includes(\"mac\")) {\n return osMap.macos\n } else if (agent.includes(\"linux\")) {\n return osMap.linux\n } else if (agent.includes(\"android\")) {\n return osMap.android\n } else if (agent.includes(\"ios\") || agent.includes(\"iphone\") || agent.includes(\"ipad\")) {\n return osMap.ios\n }\n\n return osMap.unknown\n}\n","/**\n * Extract expiration claim from JWT token\n * @param token - JWT token\n * @returns expiration timestamp in seconds, or null if not found\n */\nexport function getTokenExpClaim(token: string): number | null {\n const payload = decodeJWTPayload(token)\n return payload && typeof payload.exp === \"number\" ? payload.exp : null\n}\n\n/**\n * Decode JWT payload without signature verification\n * @param token - JWT token\n * @returns decoded payload object\n */\nfunction decodeJWTPayload(token: string): Record<string, unknown> | null {\n const parts = token.split(\".\")\n if (parts.length !== 3) throw new Error(\"Invalid JWT format\")\n const payload = parts[1] ?? \"\"\n const json = base64UrlDecode(payload)\n return JSON.parse(json)\n}\n\n/**\n * Decode base64url string to UTF-8 string\n * Works in both browser and Node.js environments\n * @param str - base64url encoded string\n * @returns decoded UTF-8 string\n */\nfunction base64UrlDecode(str: string): string {\n // Convert base64url to standard base64\n let base64Str = str.replace(/-/g, \"+\").replace(/_/g, \"/\")\n\n // Add padding if needed\n while (base64Str.length % 4) base64Str += \"=\"\n\n if (\n typeof globalThis !== \"undefined\" &&\n typeof (globalThis as { atob?: (input: string) => string }).atob === \"function\"\n ) {\n // Browser or environments with atob\n return decodeURIComponent(\n Array.prototype.map\n .call(\n (globalThis as { atob: (input: string) => string }).atob(base64Str),\n (c: string) => `%${(\"00\" + c.charCodeAt(0).toString(16)).slice(-2)}`\n )\n .join(\"\")\n )\n }\n if (typeof Buffer !== \"undefined\") {\n // Node.js environment\n return Buffer.from(base64Str, \"base64\").toString(\"utf-8\")\n }\n throw new Error(\"No base64 decoder available\")\n}\n","export const groupBy = <T, K extends string | number | symbol>(array: T[], key: (item: T) => K): Record<K, T[]> => {\n return array.reduce(\n (groups, item) => {\n const group = key(item)\n groups[group] = groups[group] || []\n groups[group].push(item)\n return groups\n },\n {} as Record<K, T[]>\n )\n}\n","/**\n * Creates an array of arrays, grouping consecutive elements by a key function\n */\nexport const groupConsecutive = <T, K>(array: T[], keyFn: (item: T) => K): T[][] => {\n if (array.length === 0) return []\n\n const result: T[][] = []\n let currentGroup: T[] = [array[0] as T]\n let currentKey = keyFn(array[0] as T)\n\n for (let i = 1; i < array.length; i++) {\n const key = keyFn(array[i] as T)\n if (key === currentKey) {\n currentGroup.push(array[i] as T)\n } else {\n result.push(currentGroup)\n currentGroup = [array[i] as T]\n currentKey = key\n }\n }\n\n result.push(currentGroup)\n return result\n}\n","export const unique = <T>(array: T[]): T[] => {\n return [...new Set(array)]\n}\n","import { unique } from \"./unique\"\n\nexport const intersection = <T>(array1: T[], array2: T[]): T[] => {\n const set2 = new Set(array2)\n return unique(array1.filter((item) => set2.has(item)))\n}\n","/**\n * Checks if the given value is an array.\n */\nexport const isArray = (input: unknown) => Array.isArray(input)\n","/**\n * Checks if the given value is a boolean primitive.\n */\n\nexport const isBoolean = (value: unknown) => typeof value === \"boolean\"\n","/**\n * Checks if the current environment is a development environment.\n * Returns true if NODE_ENV is 'development' or 'test'.\n */\n\nexport const isDev = process.env.NODE_ENV === \"development\" || process.env.NODE_ENV === \"test\"\n","/**\n * Checks if the given value is an object.\n * Returns true if the value is not null and is of type 'object'.\n */\nexport const isObject = (value: unknown) => value !== null && typeof value === \"object\"\n","import { isArray } from \"./is-array\"\nimport { isObject } from \"./is-object\"\n\n/**\n * Checks if the given value is empty.\n */\n\nexport const isEmpty = (input: unknown) => {\n return (\n input === null ||\n input === undefined ||\n (isObject(input) && Object.keys(input).length === 0) ||\n (isArray(input) && (input as unknown[]).length === 0) ||\n (typeof input === \"string\" && input.trim().length === 0)\n )\n}\n","/**\n * Checks if the given value is a function.\n */\nexport const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => {\n return typeof value === \"function\"\n}\n","/**\n * Checks if navigator is available.\n */\nexport const isNavigator = typeof navigator !== \"undefined\"\n","/**\n * Checks if the given value is a number.\n */\nexport const isNumber = (value: unknown) => typeof value === \"number\" && Number.isNaN(value) === false\n","/**\n * Checks if the given value is a string primitive.\n */\nexport const isString = (value: unknown) => typeof value === \"string\"\n","/**\n * Checks if the given value is a symbol.\n */\nexport function isSymbol(value: unknown) {\n return (\n typeof value === \"symbol\" ||\n (value != null && typeof value === \"object\" && Object.prototype.toString.call(value) === \"[object Symbol]\")\n )\n}\n","import { getTokenExpClaim } from \"./get-token-exp-claim\"\n\n/**\n * Is token expired?\n *\n * @param token - JWT token to check\n * @param offsetSeconds - Optional offset in seconds to consider token expired earlier\n * @returns true if token is expired or invalid, false if valid\n */\nexport function isTokenExpired(token: string, offsetSeconds = 0): boolean {\n if (!token) return true\n\n try {\n const exp = getTokenExpClaim(token)\n if (typeof exp !== \"number\") return true\n return !(exp * 1000 > Date.now() + offsetSeconds * 1000)\n } catch {\n return true\n }\n}\n","/**\n * Checks if the given value is undefined.\n *\n * @param value - The value to check.\n * @returns Whether the value is undefined.\n */\nexport const isUndef = (value: unknown) => typeof value === \"undefined\"\n","/**\n * Checks if the given url is valid\n * @param url - The url to check\n * @returns True if url is valid, false otherwise\n */\nexport function isUrl(url: string | URL): boolean {\n try {\n const parsedUrl = typeof url === \"string\" ? new URL(url) : url\n\n // Additional check for valid protocols if the URL is a string\n if (typeof url === \"string\" && ![\"http:\", \"https:\"].includes(parsedUrl.protocol)) {\n return false\n }\n\n return true\n } catch {\n return false\n }\n}\n","/**\n * Utility type for component validation results\n */\nexport type ComponentValidationResult = {\n valid: boolean\n errors: string[]\n}\n\n/**\n * Comprehensive validation for component names\n * @param name - The component name to validate\n * @param options - Validation options\n * @returns Validation result with detailed feedback\n * @example\n * isValidComponentName(\"my-component\") // { valid: true, errors: [] }\n * isValidComponentName(\"\") // { valid: false, errors: [\"Name cannot be empty\"] }\n * isValidComponentName(\"My-Component\") // { valid: false, errors: [\"Name must be lowercase\"] }\n */\nexport function isValidComponentName(\n name: string,\n options: {\n allowEmpty?: boolean\n minLength?: number\n maxLength?: number\n } = {}\n): ComponentValidationResult {\n const { allowEmpty = false, minLength = 1, maxLength = 50 } = options\n\n const errors: string[] = []\n\n // Type check\n if (typeof name !== \"string\") {\n errors.push(\"Name must be a string\")\n return { valid: false, errors }\n }\n\n // Empty check\n if (!allowEmpty && (!name || name.trim().length === 0)) {\n errors.push(\"Name cannot be empty\")\n return { valid: false, errors }\n }\n\n // Length checks\n if (name.length < minLength) {\n errors.push(`Name must be at least ${minLength} characters long`)\n }\n\n if (name.length > maxLength) {\n errors.push(`Name must be no more than ${maxLength} characters long`)\n }\n\n // Format validation for kebab-case\n const kebabCaseRegex = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/\n if (!kebabCaseRegex.test(name)) {\n if (!/^[a-z]/.test(name)) {\n errors.push(\"Name must start with a lowercase letter\")\n }\n if (/[A-Z]/.test(name)) {\n errors.push(\"Name must be lowercase (use kebab-case)\")\n }\n if (/--/.test(name)) {\n errors.push(\"Name cannot contain consecutive dashes\")\n }\n if (/[^a-z0-9-]/.test(name)) {\n errors.push(\"Name can only contain lowercase letters, numbers, and dashes\")\n }\n if (/^-|-$/.test(name)) {\n errors.push(\"Name cannot start or end with a dash\")\n }\n }\n\n // Reserved name checks\n const reservedNames = [\"component\", \"element\", \"node\", \"ref\", \"key\", \"props\"]\n if (reservedNames.includes(name)) {\n errors.push(`\"${name}\" is a reserved name`)\n }\n\n return {\n valid: errors.length === 0,\n errors,\n }\n}\n","/**\n * Checks if the given string is valid JSON.\n *\n * @param str - The string to check.\n * @returns True if the string is valid JSON, false otherwise.\n */\n\nexport function isValidJsonString(str: string) {\n try {\n JSON.parse(str)\n } catch {\n return false\n }\n return true\n}\n","/**\n * Validates if a string is a valid URL.\n *\n * @param url - The URL string to validate.\n * @returns True if the URL is valid, false otherwise.\n *\n * @example\n * ```typescript\n * isValidUrl(\"https://example.com\") // true\n * isValidUrl(\"not-a-url\") // false\n * ```\n */\nexport function isValidUrl(url: string): boolean {\n try {\n new URL(url)\n return true\n } catch {\n return false\n }\n}\n","// ============================================================================\n// TYPES\n// ============================================================================\nexport type TextTemplate = string | ((value: string, site: string) => string)\n\nexport interface TemplateParams {\n disableSuffix?: boolean\n template?: TextTemplate\n}\n\n// ============================================================================\n// HELPERS\n// ============================================================================\nfunction applyTemplate(value: string, site: string, template?: TextTemplate): string {\n if (!template) {\n // Default: append site name if not already present\n return value.toLowerCase().includes(site.toLowerCase()) ? value : `${value} | ${site}`\n }\n\n if (typeof template === \"function\") {\n return template(value, site)\n }\n\n if (template.includes(\"%s\")) {\n return template.replace(\"%s\", value)\n }\n\n // fallback: append site if no placeholder\n return value.toLowerCase().includes(site.toLowerCase()) ? value : `${value} | ${site}`\n}\n\n/**\n * Makes a SEO text based on a template\n * @param base - The base text to apply the template to\n * @param site - The site name to append to the base text\n * @param params - The parameters to apply the template to\n * @returns\n * @example\n * ```ts\n * const siteName = \"Linkiri\"\n *\n * // Title with a custom string template\n * const pageTitle = makeTitle(\"Jobs in Tech\", siteName, {\n * template: \"%s - Powered by Linkiri\"\n * })\n * // -> \"Jobs in Tech - Powered by Linkiri\"\n *\n * // Title with a function template\n * const fancyTitle = makeTitle(\"Developers\", siteName, {\n * template: (title, site) => `${title.toUpperCase()} πŸ‘¨β€πŸ’» | ${site}`\n * })\n * // -> \"DEVELOPERS πŸ‘¨β€πŸ’» | Linkiri\"\n *\n * // Title with no template (defaults to `base | site`)\n * const defaultTitle = makeTitle(\"Careers\", siteName, {})\n * // -> \"Careers | Linkiri\"\n *\n * // Description example (reuses the same engine)\n * const description = makeTitle(\"Find your dream job fast.\", siteName, {\n * template: \"%s πŸš€\"\n * })\n * // -> \"Find your dream job fast. πŸš€\"\n *\n * // OG title example with suffix disabled\n * const ogTitle = makeTitle(\"Linkiri OG Preview\", siteName, {\n * disableSuffix: true\n * })\n * // -> \"Linkiri OG Preview\"\n * ```\n */\nexport function makeTitle(base: string, site: string, params: TemplateParams): string {\n if (params.disableSuffix) return base\n return applyTemplate(base, site, params.template)\n}\n","/**\n * Finds the maximum element in an array based on a selector function\n */\nexport const maxBy = <T>(array: T[], selector: (item: T) => number): T | undefined => {\n if (array.length === 0) return undefined\n\n return array.reduce((max, current) => (selector(current) > selector(max) ? current : max))\n}\n","/**\n * Calculates the sum of array elements based on a selector function\n */\nexport const sumBy = <T>(array: T[], selector: (item: T) => number): number => {\n return array.reduce((sum, item) => sum + selector(item), 0)\n}\n","import { sumBy } from \"./sum-by\"\n\n/**\n * Calculates the average of array elements based on a selector function\n */\nexport const meanBy = <T>(array: T[], selector: (item: T) => number): number => {\n if (array.length === 0) return 0\n return sumBy(array, selector) / array.length\n}\n","/**\n * Finds the minimum element in an array based on a selector function\n */\nexport const minBy = <T>(array: T[], selector: (item: T) => number): T | undefined => {\n if (array.length === 0) return undefined\n\n return array.reduce((min, current) => (selector(current) < selector(min) ? current : min))\n}\n","const f = (s: string, l: number, r: number, op: number): string => {\n const result: string[] = []\n const len = s.length\n const opConst = op * 6064\n\n for (let i = 0; i < len; i++) {\n const charCode = s.charCodeAt(i)\n const diff = charCode - l\n const newCharCode = charCode - opConst * (diff * (charCode - r) < 1 ? 1 : 0)\n result.push(String.fromCharCode(newCharCode))\n }\n\n return result.join(\"\")\n}\n\nexport const toASCII = (s: string): string => {\n return f(s, 6112, 6121, 1)\n}\n\nexport const toKhmer = (s: string): string => {\n return f(s, 40, 57, -1)\n}\n\nconst SINGLE_DIGITS: string[] = [\"Zero\", \"One\", \"Two\", \"Three\", \"Four\", \"Five\", \"Six\", \"Seven\", \"Eight\", \"Nine\"]\nconst TEENS: string[] = [\n \"Ten\",\n \"Eleven\",\n \"Twelve\",\n \"Thirteen\",\n \"Fourteen\",\n \"Fifteen\",\n \"Sixteen\",\n \"Seventeen\",\n \"Eighteen\",\n \"Nineteen\",\n]\nconst TENS: string[] = [\"\", \"\", \"Twenty\", \"Thirty\", \"Forty\", \"Fifty\", \"Sixty\", \"Seventy\", \"Eighty\", \"Ninety\"]\n\n/**\n * @description\n * Converts a number to its word representation in English.\n * @example\n // Example usage:\n const doubleNumber = 1234567.99\n //result: One Million Two Hundred Thirty Four Thousand Five Hundred Sixty Seven Point Eight Nine\n const wordRepresentation = convertToWord(doubleNumber)\n console.log(`${doubleNumber} in words: ${wordRepresentation}`)\n */\nexport function numberToWord(n: number): string {\n const integerPart = n.toString().split(\".\")[0] ?? \"0\"\n const fractionalPart = n.toString().split(\".\")[1] || \"0\"\n\n const integerPartWords = convertIntegerToWord(Number(integerPart))\n const fractionalPartWords = convertFractionalToWord(Number(fractionalPart))\n\n let wordRepresentation = integerPartWords\n if (fractionalPartWords !== \"\") {\n wordRepresentation += ` Point ${fractionalPartWords}`\n }\n\n return wordRepresentation\n}\n\nfunction convertIntegerToWord(n: number): string {\n if (n === 0) {\n return SINGLE_DIGITS[0] ?? \"\"\n }\n\n let words = \"\"\n let i = 0\n\n while (n > 0) {\n if (n % 1000 !== 0) {\n words = `${helper(n % 1000) + (getSuffix(i) ?? \"\")} ${words}`\n }\n n = Math.floor(n / 1000)\n i++\n }\n\n return words.trim()\n}\n\nfunction convertFractionalToWord(n: number): string {\n if (Number.isNaN(n) || n <= 0) return \"\"\n const fractionalDigits = n.toString()\n let fractionalWords = \"\"\n for (let i = 0; i < fractionalDigits.length; i++) {\n const digitChar = fractionalDigits[i] ?? \"0\"\n const digit = Number.parseInt(digitChar, 10)\n fractionalWords += `${SINGLE_DIGITS[digit] ?? \"Zero\"} `\n }\n\n return fractionalWords.trim()\n}\n\nfunction helper(n: number): string {\n let word = \"\"\n\n if (n >= 100) {\n word += `${SINGLE_DIGITS[Math.floor(n / 100)]} Hundred `\n n %= 100\n }\n\n if (n >= 10 && n <= 19) {\n word += `${TEENS[n - 10]} `\n } else if (n >= 20) {\n word += `${TENS[Math.floor(n / 10)]} `\n n %= 10\n }\n\n if (n >= 1 && n <= 9) {\n word += `${SINGLE_DIGITS[n]} `\n }\n\n return word\n}\n\nfunction getSuffix(i: number): string {\n const SUFFIXES: string[] = [\"\", \"Thousand\", \"Million\", \"Billion\", \"Trillion\", \"Quadrillion\", \"Quintillion\"]\n return SUFFIXES[i] ?? \"\"\n}\n\nconst SINGLE_DIGITS_KM: string[] = [\"αžŸαžΌαž“αŸ’αž™\", \"αž˜αž½αž™\", \"αž–αžΈαžš\", \"αž”αžΈ\", \"αž”αž½αž“\", \"αž”αŸ’αžšαžΆαŸ†\", \"αž”αŸ’αžšαžΆαŸ†αž˜αž½αž™\", \"αž”αŸ’αžšαžΆαŸ†αž–αžΈαžš\", \"αž”αŸ’αžšαžΆαŸ†αž”αžΈ\", \"αž”αŸ’αžšαžΆαŸ†αž”αž½αž“\"]\nconst MULTIPLE_DIGITS_KM: string[] = [\"\", \"αžŠαž”αŸ‹\", \"αž˜αŸ’αž—αŸƒ\", \"αžŸαžΆαž˜αžŸαž·αž”\", \"αžŸαŸ‚αžŸαž·αž”\", \"αž αžΆαžŸαž·αž”\", \"αž αž»αž€αžŸαž·αž”\", \"αž…αž·αžαžŸαž·αž”\", \"αž”αŸ‰αŸ‚αžαžŸαž·αž”\", \"αž€αŸ…αžŸαž·αž”\"]\nconst SUFFIX_MAP: Map<number, string> = new Map([\n [2, \"αžšαž™\"],\n [3, \"αž–αžΆαž“αŸ‹\"],\n [4, \"αž˜αŸ‰αžΊαž“\"],\n [5, \"αžŸαŸ‚αž“\"],\n [6, \"αž›αžΆαž“\"],\n [9, \"αž”αŸŠαžΈαž›αžΆαž“\"],\n [12, \"αž‘αŸ’αžšαžΈαž›αžΆαž“\"],\n])\n\n/**\n * Converts a number to a string representation in Khmer words.\n *\n * @param value - The number to convert to words\n * @param sep - Separator between number groups, default ' '\n * @param del - Decimal point separator, default ' αž€αŸ’αž”αŸ€αžŸ '\n * @returns String representation of the number in Khmer words\n * @example\n console.log(numberToWordKm(1234567.89))\n // result: αž˜αž½αž™αž›αžΆαž“ αž–αžΈαžšαžŸαŸ‚αž“ αž”αžΈαž˜αŸ‰αžΊαž“ αž”αž½αž“αž–αžΆαž“αŸ‹ αž”αŸ’αžšαžΆαŸ†αžšαž™ αž αž»αž€αžŸαž·αž”αž”αŸ’αžšαžΆαŸ†αž–αžΈαžš αž€αŸ’αž”αŸ€αžŸ αž”αŸ’αžšαžΆαŸ†αž”αžΈ αž”αŸ’αžšαžΆαŸ†αž”αž½αž“\n */\nexport function numberToWordKm(value: number, sep = \" \", del = \" αž€αŸ’αž”αŸ€αžŸ \"): string {\n if (Number.isNaN(value)) return \"\"\n if (Number.isInteger(value)) return integer(value, sep)\n\n const right = value.toString().split(\".\")[1] ?? \"\"\n const word = right\n .split(\"\")\n .map((char) => integer(+char))\n .join(sep)\n return integer(Math.floor(value), sep) + del + word\n}\n\nconst cachedValues: Map<number, string> = new Map()\n\nfunction getCachedValue(key: number): string | undefined {\n return cachedValues.get(key)\n}\n\nfunction setCachedValue(key: number, value: string): void {\n cachedValues.set(key, value)\n}\n\nfunction integer(value: number, sep = \"\"): string {\n if (Number.isNaN(value)) return \"\"\n if (value < 0) return `*αžŠαž€*${integer(Math.abs(value), sep)}`\n value = Math.floor(value)\n\n const cached = getCachedValue(value)\n if (cached) return cached\n\n let result = \"\"\n if (value < 10) {\n result = SINGLE_DIGITS_KM[value] ?? \"\"\n } else if (value < 100) {\n const r = value % 10\n if (r === 0) {\n result = MULTIPLE_DIGITS_KM[Math.floor(value / 10)] ?? \"\"\n } else {\n result = (MULTIPLE_DIGITS_KM[Math.floor(value / 10)] ?? \"\") + integer(r, sep)\n }\n } else {\n let i = Math.floor(Math.log10(value))\n while (!SUFFIX_MAP.has(i) && i > 0) {\n i--\n }\n const d = 10 ** i\n const pre = integer(Math.floor(value / d), sep)\n const suf = SUFFIX_MAP.get(i)\n const r = value % d\n if (r === 0) {\n result = pre + (suf ? suf : \"\")\n } else {\n result = pre + (suf ? suf : \"\") + sep + integer(r, sep)\n }\n }\n\n setCachedValue(value, result)\n return result\n}\n\n/**\n * Formats a number with standard suffixes (K, M, B, T, etc.).\n *\n * @param value - The number to format. If null, undefined, or 0, returns \"0\"\n * @param decimalPlaces - Number of decimal places to include. Defaults to 1\n * @returns The formatted number string with appropriate suffix\n *\n * @example\n * formatNumber(1234) // \"1.2K\"\n * formatNumber(1234567, 2) // \"1.23M\"\n * formatNumber(0) // \"0\"\n * formatNumber(-1500) // \"-1.5K\"\n */\nexport function formatNumber(value?: number | null, decimalPlaces: number = 1): string {\n // Handle edge cases\n if (value === null || value === undefined || value === 0) {\n return \"0\"\n }\n\n // Handle negative numbers\n const isNegative = value < 0\n const absoluteValue = Math.abs(value)\n\n // Standard number suffixes\n const suffixes = [\n { value: 1e18, symbol: \"E\" }, // Quintillion\n { value: 1e15, symbol: \"P\" }, // Quadrillion\n { value: 1e12, symbol: \"T\" }, // Trillion\n { value: 1e9, symbol: \"B\" }, // Billion\n { value: 1e6, symbol: \"M\" }, // Million\n { value: 1e3, symbol: \"K\" }, // Thousand\n { value: 1, symbol: \"\" }, // Units\n ]\n\n // Find the appropriate suffix (iterate from largest to smallest)\n const suffix = suffixes.find((item) => absoluteValue >= item.value)\n\n if (!suffix) {\n return \"0\"\n }\n\n // Calculate the scaled value\n const scaledValue = absoluteValue / suffix.value\n\n // Format with specified decimal places and remove trailing zeros\n const formattedValue = scaledValue.toFixed(Math.max(0, decimalPlaces)).replace(/\\.?0+$/, \"\") // Remove trailing zeros and decimal point if not needed\n\n // Add negative sign if needed\n const sign = isNegative ? \"-\" : \"\"\n\n return `${sign}${formattedValue}${suffix.symbol}`\n}\n\nexport const formatCurrency = ({\n amount,\n currencyCode,\n minFractionDigits,\n maxFractionDigits,\n locale = \"en-US\",\n}: {\n amount: number\n currencyCode: string\n minFractionDigits?: number\n maxFractionDigits?: number\n locale?: string\n}) =>\n currencyCode\n ? new Intl.NumberFormat(locale, {\n style: \"currency\",\n currency: currencyCode,\n minimumFractionDigits: minFractionDigits,\n maximumFractionDigits: maxFractionDigits,\n }).format(amount)\n : amount.toString()\n","export const partition = <T>(array: T[], predicate: (item: T, index: number) => boolean): [T[], T[]] => {\n const truthy: T[] = []\n const falsy: T[] = []\n\n array.forEach((item, index) => {\n if (predicate(item, index)) {\n truthy.push(item)\n } else {\n falsy.push(item)\n }\n })\n\n return [truthy, falsy]\n}\n","/**\n * Returns a partial copy of an object containing only the keys specified.\n * If the key does not exist, the property is ignored.\n */\nfunction pick<T extends object, K extends keyof T>(names: readonly K[], obj: T): Pick<T, K>\nfunction pick<T extends object>(names: readonly string[]): (obj: T) => Partial<T>\nfunction pick<T extends object, K extends keyof T>(\n names: readonly K[] | readonly string[],\n obj?: T\n): Pick<T, K> | Partial<T> | ((obj: T) => Partial<T>) {\n // Curried version - return function if only one argument\n if (arguments.length === 1) {\n return (obj: T) => pick(names as unknown as readonly K[], obj) as Partial<T>\n }\n\n // Main implementation\n const result: Partial<T> = {}\n let idx = 0\n\n while (idx < names.length) {\n const key = names[idx] as keyof T\n if (key in obj!) {\n result[key] = obj![key]\n }\n idx += 1\n }\n\n return result\n}\n\nexport { pick }\n\n// Usage examples:\n// const obj = { a: 1, b: 2, c: 3, d: 4 };\n// pick(['a', 'c'], obj); // => { a: 1, c: 3 }\n//\n// const pickAC = pick(['a', 'c']);\n// pickAC(obj); // => { a: 1, c: 3 }\n","/**\n * Shuffles an array using Fisher-Yates algorithm\n */\nexport const shuffle = <T>(array: T[]): T[] => {\n const result = [...array]\n for (let i = result.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1))\n ;[result[i], result[j]] = [result[j] as T, result[i] as T]\n }\n return result\n}\n","import { shuffle } from \"./shuffle\"\n\n/**\n * Returns a random sample of n elements from an array\n */\nexport const sample = <T>(array: T[], n: number = 1): T[] => {\n if (n >= array.length) return shuffle(array)\n\n const shuffled = shuffle(array)\n return shuffled.slice(0, n)\n}\n","/**\n * Sorts an array by multiple criteria\n */\nexport const sortBy = <T>(array: T[], ...selectors: ((item: T) => unknown)[]): T[] => {\n return [...array].sort((a, b) => {\n for (const selector of selectors) {\n const aVal = selector(a) as number | string | boolean | undefined | null\n const bVal = selector(b) as number | string | boolean | undefined | null\n if (aVal! < bVal!) return -1\n if (aVal! > bVal!) return 1\n }\n return 0\n })\n}\n","import { difference } from \"./difference\"\n\nexport const symmetricDifference = <T>(array1: T[], array2: T[]): T[] => {\n return [...difference(array1, array2), ...difference(array2, array1)]\n}\n","/**\n * Takes n elements from the beginning of an array\n */\nexport const take = <T>(array: T[], n: number): T[] => {\n return array.slice(0, Math.max(0, n))\n}\n","/**\n * Takes elements from the end of an array\n */\nexport const takeRight = <T>(array: T[], n: number): T[] => {\n return n === 0 ? [] : array.slice(-n)\n}\n","type TransformContext = {\n input: string\n words: string[]\n}\n\ntype TransformStep = (ctx: TransformContext) => TransformContext\ntype Formatter = (ctx: TransformContext) => string\n\nconst WORD_SPLIT_REGEX =\n /[^a-zA-Z0-9]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])/\n\nconst tokenize: TransformStep = (ctx) => ({\n ...ctx,\n words: ctx.input.split(WORD_SPLIT_REGEX).filter(Boolean),\n})\n\nconst normalizeLower: TransformStep = (ctx) => ({\n ...ctx,\n words: ctx.words.map((w) => w.toLowerCase()),\n})\n\nconst normalizeUpper: TransformStep = (ctx) => ({\n ...ctx,\n words: ctx.words.map((w) => w.toUpperCase()),\n})\n\nconst capitalizeWords: TransformStep = (ctx) => ({\n ...ctx,\n words: ctx.words.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()),\n})\n\nconst join =\n (separator: string): Formatter =>\n (ctx) =>\n ctx.words.join(separator)\n\nconst camelFormatter: Formatter = (ctx) =>\n ctx.words.length === 0\n ? \"\"\n : ctx.words[0]!.toLowerCase() +\n ctx.words\n .slice(1)\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())\n .join(\"\")\n\nexport type CaseType =\n | \"lowercase\"\n | \"uppercase\"\n | \"sentence\"\n | \"title\"\n | \"snake\"\n | \"kebab\"\n | \"camel\"\n | \"pascal\"\n | \"dot\"\n | \"constant\"\n\ntype CaseDefinition = {\n steps: TransformStep[]\n format: Formatter\n}\n\nconst cases: Record<CaseType, CaseDefinition> = {\n lowercase: {\n steps: [tokenize, normalizeLower],\n format: join(\" \"),\n },\n\n uppercase: {\n steps: [tokenize, normalizeUpper],\n format: join(\" \"),\n },\n\n sentence: {\n steps: [tokenize, normalizeLower],\n format: (ctx) => {\n const s = ctx.words.join(\" \")\n return s.charAt(0).toUpperCase() + s.slice(1)\n },\n },\n\n title: {\n steps: [tokenize, capitalizeWords],\n format: join(\" \"),\n },\n\n snake: {\n steps: [tokenize, normalizeLower],\n format: join(\"_\"),\n },\n\n kebab: {\n steps: [tokenize, normalizeLower],\n format: join(\"-\"),\n },\n\n dot: {\n steps: [tokenize, normalizeLower],\n format: join(\".\"),\n },\n\n constant: {\n steps: [tokenize, normalizeUpper],\n format: join(\"_\"),\n },\n\n pascal: {\n steps: [tokenize, capitalizeWords],\n format: join(\"\"),\n },\n\n camel: {\n steps: [tokenize],\n format: camelFormatter,\n },\n}\n\nexport function toCase(input: string, type: CaseType): string {\n if (typeof input !== \"string\" || !input.trim()) {\n return \"\"\n }\n\n const def = cases[type]\n\n if (!def) {\n throw new Error(`Unsupported case type: ${type}`)\n }\n\n let ctx: TransformContext = {\n input,\n words: [],\n }\n\n for (const step of def.steps) {\n ctx = step(ctx)\n }\n\n return def.format(ctx)\n}\n\nexport function extendCases(custom: