From 9651c47541a1da8370d0040d2e323c5bf6b7eff1 Mon Sep 17 00:00:00 2001 From: sathya Date: Fri, 10 Jul 2026 08:47:16 +0530 Subject: [PATCH 1/2] feat: implement core byte formatting, parsing, and math utilities with global configuration and CI/CD publishing pipeline --- .github/workflows/npm-publish.yml | 62 +++++ README.md | 110 +++++++-- package.json | 19 +- playground.ts | 91 ++++--- src/config.ts | 74 ++++++ src/formatter.test.ts | 244 ++++++++++++++++++- src/formatter.ts | 381 +++++++++++++++++++++++++----- src/index.ts | 49 +++- src/types.ts | 28 +++ tsconfig.json | 15 +- 10 files changed, 947 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/npm-publish.yml create mode 100644 src/config.ts diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..ce37f09 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,62 @@ +name: CI & Publish Package to npmjs + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build package + run: npm run build + + publish: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Build package + run: npm run build + + - name: Check version and Publish + run: | + LOCAL_VERSION=$(node -p "require('./package.json').version") + NPM_VERSION=$(npm view bytes-kit version 2>/dev/null || echo "0.0.0") + + if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then + echo "New version detected ($LOCAL_VERSION). Publishing to npm..." + npm publish + else + echo "Version $LOCAL_VERSION is already published on npm. Skipping publication." + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 50a58d1..aa4bb5c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A lightweight, type-safe, and zero-dependency utility for formatting, parsing, c - 📦 **Dual package** (CommonJS & ES Modules) - 🔒 **Fully type-safe** with TypeScript types included - ⚙️ **Highly customizable** (decimal places, binary vs. decimal units, spacing, custom forced units) +- 🌍 **Global Configuration** with customizable error handling (throwOnError, onError) - 🧭 **Parsing & Manipulation** (parse strings back to bytes, compare sizes, get differences, calculate stats on collections) - 🌍 **Localization support** using `Intl.NumberFormat` @@ -21,6 +22,28 @@ npm install bytes-kit --- +## Global Configuration + +You can configure global defaults once. Per-call options always take precedence. + +```typescript +import bytes from 'bytes-kit'; + +bytes.defaultConfig({ + binary: true, + space: false, + throwOnError: false, // Don't crash on invalid input + onError(error) { + console.error('[App error handler]', error.message); + } +}); +``` + +* `bytes.getConfig()`: Returns a read-only clone of the current configuration. +* `bytes.resetConfig()`: Restores config back to factory defaults. + +--- + ## Usage ### 1. Formatting Bytes @@ -48,26 +71,61 @@ parseBytes('100'); // => 100 ``` ### 3. Comparing Byte Sizes -Finds the larger or smaller value between two inputs. Accepts both `number` and `string` types. +Finds the larger or smaller value between two inputs. Returns a formatted string. ```typescript import { getLargerByte, getSmallerByte } from 'bytes-kit'; -getLargerByte('1.5 MB', '2000 KB'); // => 2000000 (which is 2 MB) -getSmallerByte('1.5 MB', 2000000); // => 1500000 (which is 1.5 MB) +getLargerByte('1.5 MB', '2000 KB'); // => '2 MB' +getSmallerByte('1.5 MB', 2000000); // => '1.5 MB' ``` -### 4. Byte Difference -Computes the difference between two byte sizes (`a - b`). +### 4. Byte Equality & Difference +Checks equality or computes the difference between two byte sizes (`a - b`). ```typescript -import { diffBytes } from 'bytes-kit'; +import { isEqualBytes, diffBytes } from 'bytes-kit'; -diffBytes('1.5 MB', '500 KB'); // => 1000000 (1 MB) -diffBytes('500 KB', '1.5 MB'); // => -1000000 (-1 MB) +isEqualBytes('1 MB', '1000 KB'); // => true +isEqualBytes('1 MiB', '1024 KiB'); // => true + +diffBytes('1.5 MB', '500 KB'); // => '1 MB' +diffBytes('500 KB', '1.5 MB'); // => '-1 MB' ``` -### 5. Analyzing Collections +### 5. Summing Collections +Sums an array of byte sizes and returns the formatted sum. + +```typescript +import { sumBytes } from 'bytes-kit'; + +sumBytes(['1 MB', '500 KB']); // => '1.5 MB' +``` + +### 6. Sorting Collections +Sorts an array of byte values while preserving their original formats (string or number). Returns a new array. + +```typescript +import { sortBytes } from 'bytes-kit'; + +sortBytes(['1 MB', '200 KB', '2 GB']); // => ['200 KB', '1 MB', '2 GB'] +sortBytes(['1 MB', '200 KB', '2 GB'], 'desc'); // => ['2 GB', '1 MB', '200 KB'] +``` + +### 7. Helper Utilities +Validate representations or detect units: + +```typescript +import { isValidByte, detectUnit } from 'bytes-kit'; + +isValidByte('1 MB'); // => true +isValidByte('invalid'); // => false + +detectUnit('5 GiB'); // => 'GiB' +detectUnit('500'); // => 'B' +``` + +### 8. Analyzing Collections Parses a list of mixed sizes and returns an analysis object: ```typescript @@ -77,9 +135,9 @@ const stats = analyzeBytes(['500 KB', '1.2 MB', 3000000, '4.5 MiB']); /* Returns: { - largest: 4718592, // 4.5 MiB - smallest: 500000, // 500 KB - average: 2354648 // 2.35 MB + largest: '4.72 MB', + smallest: '500 KB', + average: '2.35 MB' } */ ``` @@ -104,17 +162,29 @@ Formats a numeric number of bytes. ### `parseBytes(input: string): number` Parses a string size back to bytes. Supports standard decimal (B, KB, MB...) and binary (KiB, MiB, GiB...) units case-insensitively. -### `getLargerByte(a: number | string, b: number | string): number` -Compares `a` and `b` and returns the larger size in bytes. +### `getLargerByte(a: number | string, b: number | string, options?: FormatOptions): string` +Compares `a` and `b` and returns the larger size formatted as a string. + +### `getSmallerByte(a: number | string, b: number | string, options?: FormatOptions): string` +Compares `a` and `b` and returns the smaller size formatted as a string. + +### `diffBytes(a: number | string, b: number | string, options?: FormatOptions): string` +Returns the difference `a - b` formatted as a string. + +### `isEqualBytes(a: number | string, b: number | string): boolean` +Returns true if `a` and `b` represent equivalent byte capacities. + +### `sumBytes(values: (number | string)[], options?: FormatOptions): string` +Sums the array of byte sizes and returns the formatted sum. -### `getSmallerByte(a: number | string, b: number | string): number` -Compares `a` and `b` and returns the smaller size in bytes. +### `sortBytes(values: (number | string)[], order?: 'asc' | 'desc'): (number | string)[]` +Sorts the collection of byte sizes and returns a new sorted array. -### `diffBytes(a: number | string, b: number | string): number` -Returns the difference `a - b` in bytes. +### `isValidByte(value: number | string): boolean` +Returns true if `value` is a valid byte representation. -### `analyzeBytes(inputs: (number | string)[]): ByteStats` -Returns the `largest`, `smallest`, and `average` byte sizes from an array of mixed byte inputs. +### `detectUnit(value: string): UnitType` +Returns the detected `UnitType` symbol of the byte representation. --- diff --git a/package.json b/package.json index 1f97ba5..140ed2e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bytes-kit", "version": "1.1.0", - "description": "A comprehensive bytes toolkit for formatting, parsing, and working with byte sizes.", + "description": "Utilities for formatting, parsing, and working with byte sizes.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -26,6 +26,7 @@ "homepage": "https://github.com/cool-Dev-master/bytes-kit#readme", "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts", + "clean": "rm -rf dist", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "play": "tsx playground.ts", "test": "vitest run", @@ -41,13 +42,23 @@ "keywords": [ "bytes", "byte", + "bytes-kit", + "byte-kit", "format", "formatter", "toolkit", "size", "human-readable", - "parser" + "parser", + "bytes-converter", + "filesize", + "binary" ], + "sideEffects": false, "author": "Cool Dev Master ", - "license": "MIT" -} + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/cool-Dev-master" + } +} \ No newline at end of file diff --git a/playground.ts b/playground.ts index 3fbb39a..0f7b779 100644 --- a/playground.ts +++ b/playground.ts @@ -1,52 +1,83 @@ -import { +import bytes, { formatBytes, parseBytes, getLargerByte, getSmallerByte, + isEqualBytes, diffBytes, + sumBytes, + sortBytes, analyzeBytes, + isValidByte, + detectUnit, } from './src/index.js'; console.log('=== Bytes Kit Playground ===\n'); -// 1. Formatting Bytes -console.log('--- 1. Formatting Bytes ---'); -const bytes = 123456789; -console.log(`Input value: ${bytes} bytes`); -console.log('Default format: ', formatBytes(bytes)); -console.log('Binary units: ', formatBytes(bytes, { binary: true })); -console.log('Fixed decimals (3): ', formatBytes(bytes, { decimalPlaces: 3, fixedDecimals: true })); -console.log('German locale: ', formatBytes(bytes, { locale: 'de-DE' })); +// 1. Configure Defaults +bytes.defaultConfig({ + binary: true, + space: false, + throwOnError: false, + onError(err) { + console.log('[Global Config onError Hook] Caught error:', err.message); + } +}); + +console.log('--- 1. Configuration Check ---'); +console.log('Current Config:', bytes.getConfig()); +console.log(); + +// 2. Formatting Bytes +console.log('--- 2. Formatting Bytes ---'); +const bytesVal = 123456789; +console.log(`Input value: ${bytesVal} bytes`); +console.log('Default format (binary & no space via config):', formatBytes(bytesVal)); +console.log('Per-call override (decimal & space): ', formatBytes(bytesVal, { binary: false, space: true })); console.log(); -// 2. Parsing Bytes -console.log('--- 2. Parsing Byte Strings ---'); +// 3. Parsing Bytes +console.log('--- 3. Parsing Byte Strings ---'); const string1 = '1.5 MB'; const string2 = '20 KiB'; console.log(`parseBytes("${string1}"):`, parseBytes(string1), 'bytes'); console.log(`parseBytes("${string2}"):`, parseBytes(string2), 'bytes'); console.log(); -// 3. Finding Larger / Smaller Sizes (mixed arguments, returns string) -console.log('--- 3. Larger / Smaller Comparisons ---'); -const sizeA = '1.5 MB'; -const sizeB = '2000 KB'; // 2.0 MB -console.log(`getLargerByte("${sizeA}", "${sizeB}"): `, getLargerByte(sizeA, sizeB)); -console.log(`getSmallerByte("${sizeA}", "${sizeB}"): `, getSmallerByte(sizeA, sizeB)); +// 4. Verification & Helpers +console.log('--- 4. Verification & Helpers ---'); +console.log('isValidByte("1.5 MB"):', isValidByte('1.5 MB')); +console.log('isValidByte("abc"): ', isValidByte('abc')); +console.log('detectUnit("5 GiB"): ', detectUnit('5 GiB')); +console.log('detectUnit("500"): ', detectUnit('500')); +console.log(); + +// 5. Comparisons, Equality & Differences +console.log('--- 5. Comparisons, Equality & Differences ---'); +console.log('isEqualBytes("1 MB", "1000 KB"): ', isEqualBytes('1 MB', '1000 KB')); +console.log('getLargerByte("1.5 MB", "2000 KB"):', getLargerByte('1.5 MB', '2000 KB')); +console.log('getSmallerByte("1.5 MB", 2000000): ', getSmallerByte('1.5 MB', 2000000)); +console.log('diffBytes("1.5 MB", "500 KB"): ', diffBytes('1.5 MB', '500 KB')); +console.log(); + +// 6. Math & Collection Operations +console.log('--- 6. Math & Collection Operations ---'); +const arr = ['1 MB', '500 KB', '2 GB']; +console.log('Array of sizes:', arr); +console.log('sumBytes(arr): ', sumBytes(arr)); +console.log('sumBytes(arr, { unit: "MB" }): ', sumBytes(arr, { unit: 'MB' })); +console.log('sortBytes(arr): ', sortBytes(arr)); +console.log('analyzeBytes(arr):', analyzeBytes(arr)); console.log(); -// 4. Calculating Difference (returns string) -console.log('--- 4. Difference ---'); -console.log(`diffBytes("1.5 MB", "500 KB"): `, diffBytes('1.5 MB', '500 KB')); -console.log(`diffBytes("500 KB", "1.5 MB"): `, diffBytes('500 KB', '1.5 MB')); +// 7. Error Handling Fallback Check +console.log('--- 7. Error Handling Fallback Check ---'); +console.log('isValidByte("invalid"): ', isValidByte('invalid')); // never calls onError +console.log('parseBytes("invalid"): ', parseBytes('invalid')); // calls onError, returns 0 +console.log('sumBytes(["1 MB", "invalid"]): ', sumBytes(['1 MB', 'invalid'])); // calls onError, returns fallback console.log(); -// 5. Analyzing Collection Stats (returns formatted strings) -console.log('--- 5. Collection Analysis ---'); -const fileSizes = ['500 KB', '1.2 MB', 3000000, '4.5 MiB']; -const stats = analyzeBytes(fileSizes); -console.log('Input Array:', fileSizes); -console.log('Stats:'); -console.log(' Largest: ', stats.largest); -console.log(' Smallest: ', stats.smallest); -console.log(' Average: ', stats.average); +// Reset config +bytes.resetConfig(); +console.log('--- 8. Configuration Reset ---'); +console.log('Reset Config:', bytes.getConfig()); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..8c97514 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,74 @@ +import type { GlobalConfig } from './types.js'; + +declare const console: any; + +// --------------------------------------------------------------------------- +// Module-level singleton — the only source of truth for global config. +// --------------------------------------------------------------------------- +const _defaultState: GlobalConfig = { + throwOnError: true, +}; + +let _config: GlobalConfig = { ..._defaultState }; + +/** + * Sets global default options for all bytes-kit functions. + * Per-call options always take precedence over these defaults. + * + * @example + * import bytes from 'bytes-kit'; + * bytes.defaultConfig({ binary: true, throwOnError: false }); + */ +export function defaultConfig(config: Partial): void { + _config = { ..._config, ...config }; +} + +/** + * Returns the current global configuration snapshot. + */ +export function getConfig(): Readonly { + return { ..._config }; +} + +/** + * Resets the global configuration back to factory defaults. + * Primarily useful in tests to ensure isolation between test cases. + */ +export function resetConfig(): void { + _config = { ..._defaultState }; +} + +/** + * Centralised error handler. Respects the merged configuration of the + * current global config and any per-call overrides. + * + * - When `throwOnError` is `true` (default): re-throws the error. + * - When `throwOnError` is `false`: calls `onError(error)` if provided, + * otherwise calls `console.error`, then returns `fallback`. + * + * @internal + */ +export function handleError( + error: Error, + fallback: T, + mergedConfig: Readonly, +): T { + if (mergedConfig.throwOnError !== false) { + throw error; + } + if (typeof mergedConfig.onError === 'function') { + mergedConfig.onError(error); + } else { + console.error(`[bytes-kit] ${error.message}`); + } + return fallback; +} + +/** + * Merges the global config with per-call options. + * Per-call options always win over global defaults. + * @internal + */ +export function mergeConfig(options?: Partial): GlobalConfig { + return { ..._config, ...options }; +} diff --git a/src/formatter.test.ts b/src/formatter.test.ts index 0e95f07..824d989 100644 --- a/src/formatter.test.ts +++ b/src/formatter.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { formatBytes, parseBytes, @@ -6,7 +6,18 @@ import { getSmallerByte, diffBytes, analyzeBytes, + isEqualBytes, + sumBytes, + sortBytes, + isValidByte, + detectUnit, } from './formatter.js'; +import { defaultConfig, getConfig, resetConfig } from './config.js'; + +// Always restore global config after each test so tests are isolated. +afterEach(() => { + resetConfig(); +}); describe('bytes-kit formatter', () => { describe('validation', () => { @@ -157,7 +168,7 @@ describe('bytes-kit utilities', () => { expect(parseBytes('-1 KiB')).toBe(-1024); }); - it('throws errors for invalid formats or units', () => { + it('throws on invalid formats or units (default throwOnError: true)', () => { expect(() => parseBytes('abc')).toThrow(); expect(() => parseBytes('10 MBB')).toThrow(); expect(() => parseBytes('10.5.5 MB')).toThrow(); @@ -208,13 +219,238 @@ describe('bytes-kit utilities', () => { expect(binaryStats.average).toBe('1.5 KiB'); }); - it('throws error for empty array', () => { + it('throws error for empty array (default throwOnError: true)', () => { expect(() => analyzeBytes([])).toThrow(); }); - it('throws TypeError for non-array', () => { + it('throws TypeError for non-array (default throwOnError: true)', () => { // @ts-expect-error - testing runtime type validation expect(() => analyzeBytes(null)).toThrow(TypeError); }); }); }); + +// --------------------------------------------------------------------------- +describe('bytes-kit GlobalConfig', () => { + describe('defaultConfig + throwOnError: false', () => { + it('formatBytes returns fallback instead of throwing', () => { + defaultConfig({ throwOnError: false }); + // @ts-expect-error - intentional bad input + expect(formatBytes('not-a-number')).toBe('0 B'); + expect(formatBytes(NaN)).toBe('0 B'); + }); + + it('parseBytes returns 0 instead of throwing', () => { + defaultConfig({ throwOnError: false }); + expect(parseBytes('bad input')).toBe(0); + // @ts-expect-error - intentional bad input + expect(parseBytes(null)).toBe(0); + }); + + it('analyzeBytes returns empty stats instead of throwing', () => { + defaultConfig({ throwOnError: false }); + const result = analyzeBytes([]); + expect(result).toEqual({ largest: '', smallest: '', average: '' }); + }); + }); + + describe('global formatting defaults', () => { + it('applies global binary: true to formatBytes', () => { + defaultConfig({ binary: true }); + expect(formatBytes(1024)).toBe('1 KiB'); + }); + + it('per-call option overrides global default', () => { + defaultConfig({ binary: true }); + expect(formatBytes(1000, { binary: false })).toBe('1 KB'); + }); + + it('applies global space: false to formatBytes', () => { + defaultConfig({ space: false }); + expect(formatBytes(1000)).toBe('1KB'); + expect(formatBytes(1024, { binary: true })).toBe('1KiB'); + }); + + it('applies global decimalPlaces to all formatting functions', () => { + defaultConfig({ decimalPlaces: 0 }); + expect(formatBytes(1337)).toBe('1 KB'); + expect(getLargerByte('1.5 MB', '2.7 MB')).toBe('3 MB'); + expect(diffBytes('2.9 MB', '1 MB')).toBe('2 MB'); + }); + }); + + describe('onError callback', () => { + it('calls onError instead of throwing when throwOnError: false', () => { + const errors: Error[] = []; + defaultConfig({ + throwOnError: false, + onError: (err) => errors.push(err), + }); + parseBytes('invalid'); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toContain('Invalid byte representation'); + }); + + it('does not call onError when input is valid', () => { + const errors: Error[] = []; + defaultConfig({ + throwOnError: false, + onError: (err) => errors.push(err), + }); + parseBytes('1 MB'); + expect(errors).toHaveLength(0); + }); + }); + + describe('resetConfig', () => { + it('restores default throw behavior after reset', () => { + defaultConfig({ throwOnError: false }); + // @ts-expect-error - intentional bad input + expect(formatBytes('oops')).toBe('0 B'); // no throw + resetConfig(); + // @ts-expect-error - intentional bad input + expect(() => formatBytes('oops')).toThrow(); // back to throw + }); + }); + + describe('getConfig', () => { + it('returns a read-only clone of the current configuration', () => { + defaultConfig({ binary: true, space: false }); + const config = getConfig(); + expect(config.binary).toBe(true); + expect(config.space).toBe(false); + expect(config.throwOnError).toBe(true); + + // Mutating the returned config object should not mutate internal state + // @ts-expect-error - testing readonly / mutation avoidance + config.space = true; + expect(getConfig().space).toBe(false); + }); + }); +}); + +describe('bytes-kit New Utility APIs', () => { + describe('isValidByte', () => { + it('returns true for valid byte inputs', () => { + expect(isValidByte('1 MB')).toBe(true); + expect(isValidByte('5 GiB')).toBe(true); + expect(isValidByte(1024)).toBe(true); + expect(isValidByte(0)).toBe(true); + expect(isValidByte('-50 KB')).toBe(true); + }); + + it('returns false for invalid byte inputs', () => { + expect(isValidByte('abc')).toBe(false); + expect(isValidByte('1 XB')).toBe(false); + expect(isValidByte(NaN)).toBe(false); + expect(isValidByte(Infinity)).toBe(false); + // @ts-expect-error - testing invalid type + expect(isValidByte(null)).toBe(false); + }); + + it('never throws or calls onError', () => { + let called = false; + defaultConfig({ + throwOnError: false, + onError: () => { called = true; } + }); + expect(isValidByte('invalid')).toBe(false); + expect(called).toBe(false); + }); + }); + + describe('detectUnit', () => { + it('detects the unit correctly', () => { + expect(detectUnit('5 GiB')).toBe('GiB'); + expect(detectUnit('20 MB')).toBe('MB'); + expect(detectUnit('500')).toBe('B'); + expect(detectUnit(' 1.5 kb ')).toBe('KB'); + }); + + it('respects global error handling on invalid unit/input', () => { + // throwOnError is default true: + expect(() => detectUnit('abc')).toThrow(); + + // throwOnError: false: + defaultConfig({ throwOnError: false }); + expect(detectUnit('abc')).toBe('B'); + }); + }); + + describe('isEqualBytes', () => { + it('compares equal normalized byte values', () => { + expect(isEqualBytes('1 MB', '1000 KB')).toBe(true); + expect(isEqualBytes('1 MiB', '1024 KiB')).toBe(true); + expect(isEqualBytes(1000, '1 KB')).toBe(true); + }); + + it('returns false for unequal byte values', () => { + expect(isEqualBytes('1 MB', '1 MiB')).toBe(false); + expect(isEqualBytes(1000, 2000)).toBe(false); + }); + + it('respects global error handling', () => { + expect(() => isEqualBytes('invalid', '1 MB')).toThrow(); + + defaultConfig({ throwOnError: false }); + // since both return fallback 0, 0 === 0 is true + expect(isEqualBytes('invalid', 'another-invalid')).toBe(true); + }); + }); + + describe('sumBytes', () => { + it('sums byte values correctly', () => { + expect(sumBytes(['1 MB', '500 KB'])).toBe('1.5 MB'); + expect(sumBytes([1000, '2 KB'])).toBe('3 KB'); + }); + + it('respects per-call options and global configuration', () => { + expect(sumBytes(['1 KiB', '2 KiB'], { binary: true })).toBe('3 KiB'); + + defaultConfig({ binary: true }); + expect(sumBytes(['1 KiB', '2 KiB'])).toBe('3 KiB'); + }); + + it('respects global error handling', () => { + expect(() => sumBytes(['1 MB', 'invalid'])).toThrow(); + + defaultConfig({ throwOnError: false }); + expect(sumBytes(['1 MB', 'invalid'])).toBe('1 MB'); // invalid parses to 0 + }); + }); + + describe('sortBytes', () => { + it('sorts ascending by default', () => { + const values = ['1 MB', '200 KB', '2 GB']; + expect(sortBytes(values)).toEqual(['200 KB', '1 MB', '2 GB']); + }); + + it('sorts descending when order is desc', () => { + const values = ['1 MB', '200 KB', '2 GB']; + expect(sortBytes(values, 'desc')).toEqual(['2 GB', '1 MB', '200 KB']); + }); + + it('preserves the original representations', () => { + const values = [1000, '200 B', '1.5 KB']; + expect(sortBytes(values)).toEqual(['200 B', 1000, '1.5 KB']); + }); + + it('returns a new array (preserves original)', () => { + const values = ['2 MB', '1 MB']; + const sorted = sortBytes(values); + expect(sorted).not.toBe(values); + expect(values).toEqual(['2 MB', '1 MB']); + }); + + it('respects global error handling', () => { + expect(() => sortBytes(['1 MB', 'invalid'])).toThrow(); + + defaultConfig({ throwOnError: false }); + // invalid maps to 0, sorting should position it first + expect(sortBytes(['1 MB', 'invalid'])).toEqual(['invalid', '1 MB']); + }); + }); +}); + + diff --git a/src/formatter.ts b/src/formatter.ts index 11b2f8d..d83dd39 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -1,28 +1,30 @@ -import { FormatOptions, UnitType, ByteStats } from './types.js'; +import { FormatOptions, UnitType, ByteStats, GlobalConfig } from './types.js'; +import { getConfig, mergeConfig, handleError } from './config.js'; const DECIMAL_UNITS: UnitType[] = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const BINARY_UNITS: UnitType[] = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; -/** - * Formats a number of bytes into a human-readable string (e.g., '10.5 MB' or '2 GiB'). - * - * @param bytes The number of bytes to format. Must be a finite number. - * @param options Custom formatting options. - * @returns The formatted string representation of the byte size. - * @throws {TypeError} If the input bytes is not a finite number. - */ -export function formatBytes(bytes: number, options: FormatOptions = {}): string { - if (typeof bytes !== 'number' || !Number.isFinite(bytes)) { - throw new TypeError('Expected a finite number of bytes'); - } +// Map of lower-cased unit symbols to their base and power multiplier. +const UNIT_POWER_MAP: Record = {}; +DECIMAL_UNITS.forEach((unit, idx) => { + UNIT_POWER_MAP[unit.toLowerCase()] = { base: 1000, exponent: idx }; +}); +BINARY_UNITS.forEach((unit, idx) => { + UNIT_POWER_MAP[unit.toLowerCase()] = { base: 1024, exponent: idx }; +}); +// --------------------------------------------------------------------------- +// Internal core: performs the actual formatting. Always throws on invalid +// input — callers are responsible for wrapping with handleError when needed. +// --------------------------------------------------------------------------- +function _formatBytesCore(bytes: number, opts: FormatOptions): string { const { decimalPlaces = 2, fixedDecimals = false, binary = false, space = true, locale = false, - } = options; + } = opts; const isNegative = bytes < 0; const absoluteBytes = Math.abs(bytes); @@ -32,23 +34,23 @@ export function formatBytes(bytes: number, options: FormatOptions = {}): string let index = -1; // 1. Determine Unit and Base - if (options.unit) { - if (options.unit === 'B') { + if (opts.unit) { + if (opts.unit === 'B') { index = 0; } else { - const decIndex = DECIMAL_UNITS.indexOf(options.unit); + const decIndex = DECIMAL_UNITS.indexOf(opts.unit); if (decIndex !== -1) { base = 1000; units = DECIMAL_UNITS; index = decIndex; } else { - const binIndex = BINARY_UNITS.indexOf(options.unit); + const binIndex = BINARY_UNITS.indexOf(opts.unit); if (binIndex !== -1) { base = 1024; units = BINARY_UNITS; index = binIndex; } else { - throw new Error(`Invalid unit forced: ${options.unit}`); + throw new Error(`Invalid unit forced: ${opts.unit}`); } } } @@ -56,18 +58,17 @@ export function formatBytes(bytes: number, options: FormatOptions = {}): string // 2. Handle 0 Bytes if (absoluteBytes === 0) { - const unitSymbol = options.unit || 'B'; - const dec = decimalPlaces; + const unitSymbol = opts.unit || 'B'; let formattedZero: string; if (locale) { const localeStr = typeof locale === 'string' ? locale : undefined; formattedZero = new Intl.NumberFormat(localeStr, { - minimumFractionDigits: fixedDecimals ? dec : 0, - maximumFractionDigits: dec, + minimumFractionDigits: fixedDecimals ? decimalPlaces : 0, + maximumFractionDigits: decimalPlaces, }).format(0); } else { - formattedZero = (0).toFixed(dec); + formattedZero = (0).toFixed(decimalPlaces); if (!fixedDecimals && formattedZero.includes('.')) { formattedZero = formattedZero.replace(/0+$/, '').replace(/\.$/, ''); } @@ -86,7 +87,7 @@ export function formatBytes(bytes: number, options: FormatOptions = {}): string // 4. Double-check for rounding up overflow (e.g. 999.999 KB -> 1.00 MB) // Only apply when the unit is not explicitly forced - if (!options.unit) { + if (!opts.unit) { const roundedValue = Number(value.toFixed(decimalPlaces)); if (roundedValue >= base && index < units.length - 1) { index++; @@ -115,31 +116,59 @@ export function formatBytes(bytes: number, options: FormatOptions = {}): string return `${sign}${formattedValue}${space ? ' ' : ''}${unitSymbol}`; } -// Map of lower-cased unit symbols to their base and power multiplier. -const UNIT_POWER_MAP: Record = {}; -DECIMAL_UNITS.forEach((unit, idx) => { - UNIT_POWER_MAP[unit.toLowerCase()] = { base: 1000, exponent: idx }; -}); -BINARY_UNITS.forEach((unit, idx) => { - UNIT_POWER_MAP[unit.toLowerCase()] = { base: 1024, exponent: idx }; -}); +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Formats a number of bytes into a human-readable string (e.g., '10.5 MB' or '2 GiB'). + * + * @param bytes The number of bytes to format. Must be a finite number. + * @param options Per-call formatting options. Overrides any global defaults. + * @returns The formatted string, or `'0 B'` if `throwOnError` is false and input is invalid. + */ +export function formatBytes(bytes: number, options?: FormatOptions): string { + const cfg = mergeConfig(options); + + if (typeof bytes !== 'number' || !Number.isFinite(bytes)) { + return handleError( + new TypeError('Expected a finite number of bytes'), + '0 B', + cfg, + ); + } + + try { + return _formatBytesCore(bytes, cfg); + } catch (err) { + return handleError(err instanceof Error ? err : new Error(String(err)), '0 B', cfg); + } +} /** * Parses a human-readable byte string (e.g. '10 MB', '2.5 GiB', '-500 KB') into bytes. * * @param input The string to parse. - * @returns The parsed number of bytes. - * @throws {TypeError} If the input is not a string. - * @throws {Error} If the input format or unit is invalid. + * @returns The parsed number of bytes, or `0` if `throwOnError` is false and input is invalid. */ export function parseBytes(input: string): number { + const cfg = getConfig(); + if (typeof input !== 'string') { - throw new TypeError('Expected a string to parse'); + return handleError( + new TypeError('Expected a string to parse'), + 0, + cfg, + ); } const match = /^\s*([+-]?\d+(?:\.\d+)?)\s*([a-zA-Z]*)\s*$/.exec(input); if (!match) { - throw new Error(`Invalid byte representation: "${input}"`); + return handleError( + new Error(`Invalid byte representation: "${input}"`), + 0, + cfg, + ); } const numericValue = parseFloat(match[1]); @@ -151,69 +180,295 @@ export function parseBytes(input: string): number { const unitConfig = UNIT_POWER_MAP[unitStr]; if (!unitConfig) { - throw new Error(`Invalid unit: "${match[2]}"`); + return handleError( + new Error(`Invalid unit: "${match[2]}"`), + 0, + cfg, + ); } return numericValue * Math.pow(unitConfig.base, unitConfig.exponent); } /** - * Normalizes input (number or string) to a number of bytes. + * Normalizes input (number or string) to a raw number of bytes. + * + * @returns Raw bytes as a number, or `0` if `throwOnError` is false and input is invalid. */ export function toBytes(input: number | string): number { + const cfg = getConfig(); + if (typeof input === 'number') { if (!Number.isFinite(input)) { - throw new TypeError('Expected a finite number of bytes'); + return handleError( + new TypeError('Expected a finite number of bytes'), + 0, + cfg, + ); } return input; } if (typeof input === 'string') { return parseBytes(input); } - throw new TypeError('Expected a number or a string representing bytes'); + return handleError( + new TypeError('Expected a number or a string representing bytes'), + 0, + cfg, + ); } /** * Compares two byte sizes and returns the larger size formatted as a string. + * Accepts both raw numbers and human-readable strings (e.g. '1.5 MB'). + * + * @returns Formatted string of the larger value, or `''` on error when `throwOnError` is false. */ -export function getLargerByte(a: number | string, b: number | string, options?: FormatOptions): string { - return formatBytes(Math.max(toBytes(a), toBytes(b)), options); +export function getLargerByte( + a: number | string, + b: number | string, + options?: FormatOptions, +): string { + const cfg = mergeConfig(options); + try { + return _formatBytesCore(Math.max(toBytes(a), toBytes(b)), cfg); + } catch (err) { + return handleError(err instanceof Error ? err : new Error(String(err)), '', cfg); + } } /** * Compares two byte sizes and returns the smaller size formatted as a string. + * Accepts both raw numbers and human-readable strings. + * + * @returns Formatted string of the smaller value, or `''` on error when `throwOnError` is false. */ -export function getSmallerByte(a: number | string, b: number | string, options?: FormatOptions): string { - return formatBytes(Math.min(toBytes(a), toBytes(b)), options); +export function getSmallerByte( + a: number | string, + b: number | string, + options?: FormatOptions, +): string { + const cfg = mergeConfig(options); + try { + return _formatBytesCore(Math.min(toBytes(a), toBytes(b)), cfg); + } catch (err) { + return handleError(err instanceof Error ? err : new Error(String(err)), '', cfg); + } } /** - * Calculates the difference between two byte sizes (a - b) and returns it formatted as a string. + * Calculates the difference between two byte sizes (`a − b`) and returns it formatted as a string. + * Accepts both raw numbers and human-readable strings. Result may be negative. + * + * @returns Formatted difference string, or `''` on error when `throwOnError` is false. */ -export function diffBytes(a: number | string, b: number | string, options?: FormatOptions): string { - return formatBytes(toBytes(a) - toBytes(b), options); +export function diffBytes( + a: number | string, + b: number | string, + options?: FormatOptions, +): string { + const cfg = mergeConfig(options); + try { + return _formatBytesCore(toBytes(a) - toBytes(b), cfg); + } catch (err) { + return handleError(err instanceof Error ? err : new Error(String(err)), '', cfg); + } } +/** Fallback returned by `analyzeBytes` when input is invalid and `throwOnError` is false. */ +const EMPTY_STATS: Readonly = { largest: '', smallest: '', average: '' }; + /** - * Analyzes an array of byte sizes to find the largest, smallest, and average, returning them formatted as strings. + * Analyzes an array of byte sizes and returns the largest, smallest, and average + * formatted as human-readable strings. + * + * @returns A `ByteStats` object, or `{ largest: '', smallest: '', average: '' }` on error + * when `throwOnError` is false. */ -export function analyzeBytes(inputs: (number | string)[], options?: FormatOptions): ByteStats { +export function analyzeBytes( + inputs: (number | string)[], + options?: FormatOptions, +): ByteStats { + const cfg = mergeConfig(options); + if (!Array.isArray(inputs)) { - throw new TypeError('Expected an array of byte values'); + return handleError( + new TypeError('Expected an array of byte values'), + { ...EMPTY_STATS }, + cfg, + ); } if (inputs.length === 0) { - throw new Error('Cannot analyze an empty array'); + return handleError( + new Error('Cannot analyze an empty array'), + { ...EMPTY_STATS }, + cfg, + ); } - const byteValues = inputs.map(toBytes); - const largest = Math.max(...byteValues); - const smallest = Math.min(...byteValues); - const sum = byteValues.reduce((acc, val) => acc + val, 0); - const average = sum / byteValues.length; + try { + const byteValues = inputs.map(toBytes); + const largest = Math.max(...byteValues); + const smallest = Math.min(...byteValues); + const sum = byteValues.reduce((acc, val) => acc + val, 0); + const average = sum / byteValues.length; + + return { + largest: _formatBytesCore(largest, cfg), + smallest: _formatBytesCore(smallest, cfg), + average: _formatBytesCore(average, cfg), + }; + } catch (err) { + return handleError( + err instanceof Error ? err : new Error(String(err)), + { ...EMPTY_STATS }, + cfg, + ); + } +} - return { - largest: formatBytes(largest, options), - smallest: formatBytes(smallest, options), - average: formatBytes(average, options), - }; +/** + * Validates whether a value is a valid byte representation (finite number or properly formatted string). + * Never throws and never calls the global onError callback. + * + * @param value The value to validate. + * @returns True if valid, false otherwise. + */ +export function isValidByte(value: number | string): boolean { + if (typeof value === 'number') { + return Number.isFinite(value); + } + if (typeof value === 'string') { + const match = /^\s*([+-]?\d+(?:\.\d+)?)\s*([a-zA-Z]*)\s*$/.exec(value); + if (!match) { + return false; + } + const unitStr = match[2].trim().toLowerCase(); + if (!unitStr) { + return true; + } + return unitStr in UNIT_POWER_MAP; + } + return false; +} + +/** + * Detects the unit used in a human-readable byte string. + * Respects global error handling if the value or unit is invalid. + * + * @param value The string to inspect. + * @returns The detected UnitType (defaults to 'B' on fallback). + */ +export function detectUnit(value: string): UnitType { + const cfg = getConfig(); + if (typeof value !== 'string') { + return handleError( + new TypeError('Expected a string to detect unit'), + 'B' as UnitType, + cfg, + ); + } + const match = /^\s*([+-]?\d+(?:\.\d+)?)\s*([a-zA-Z]*)\s*$/.exec(value); + if (!match) { + return handleError( + new Error(`Invalid byte representation: "${value}"`), + 'B' as UnitType, + cfg, + ); + } + const unitStr = match[2].trim(); + if (!unitStr) { + return 'B'; + } + const unitLower = unitStr.toLowerCase(); + if (!(unitLower in UNIT_POWER_MAP)) { + return handleError( + new Error(`Invalid unit: "${unitStr}"`), + 'B' as UnitType, + cfg, + ); + } + const foundUnit = + BINARY_UNITS.find((u) => u.toLowerCase() === unitLower) || + DECIMAL_UNITS.find((u) => u.toLowerCase() === unitLower); + return (foundUnit || 'B') as UnitType; } + +/** + * Checks if two byte sizes are equal by comparing their normalized byte values. + * Respects global error handling. + * + * @param a The first byte size. + * @param b The second byte size. + * @returns True if equal, false otherwise. + */ +export function isEqualBytes(a: number | string, b: number | string): boolean { + const cfg = getConfig(); + try { + return toBytes(a) === toBytes(b); + } catch (err) { + return handleError(err instanceof Error ? err : new Error(String(err)), false, cfg); + } +} + +/** + * Sums an array of byte sizes and returns the formatted sum. + * Respects global configuration, per-call options, and global error handling. + * + * @param values Array of numbers or strings representing byte sizes. + * @param options Per-call formatting options for the output. + * @returns Formatted representation of the sum. + */ +export function sumBytes(values: (number | string)[], options?: FormatOptions): string { + const cfg = mergeConfig(options); + if (!Array.isArray(values)) { + return handleError( + new TypeError('Expected an array of byte values'), + '0 B', + cfg, + ); + } + try { + const sum = values.reduce((acc, val) => acc + toBytes(val), 0); + return _formatBytesCore(sum, cfg); + } catch (err) { + return handleError(err instanceof Error ? err : new Error(String(err)), '0 B', cfg); + } +} + +/** + * Sorts an array of byte sizes, preserving their original string/number representation. + * Returns a new array. Respects global error handling. + * + * @param values Array of byte values to sort. + * @param order Sort direction: 'asc' or 'desc' (defaults to 'asc'). + * @returns A new sorted array. + */ +export function sortBytes( + values: (number | string)[], + order: 'asc' | 'desc' = 'asc', +): (number | string)[] { + const cfg = getConfig(); + if (!Array.isArray(values)) { + return handleError( + new TypeError('Expected an array to sort'), + [], + cfg, + ); + } + try { + const parsedMap = new Map(); + for (const val of values) { + parsedMap.set(val, toBytes(val)); + } + const sorted = [...values].sort((a, b) => { + const valA = parsedMap.get(a) ?? 0; + const valB = parsedMap.get(b) ?? 0; + return order === 'desc' ? valB - valA : valA - valB; + }); + return sorted; + } catch (err) { + return handleError(err instanceof Error ? err : new Error(String(err)), [], cfg); + } +} + diff --git a/src/index.ts b/src/index.ts index c0b73d2..231f0f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,57 @@ export { formatBytes, parseBytes, + toBytes, getLargerByte, getSmallerByte, + isEqualBytes, diffBytes, + sumBytes, + sortBytes, analyzeBytes, + isValidByte, + detectUnit, } from './formatter.js'; -export type { FormatOptions, UnitType, ByteStats } from './types.js'; +export { defaultConfig, getConfig, resetConfig } from './config.js'; + +export type { FormatOptions, UnitType, ByteStats, GlobalConfig } from './types.js'; + +// --------------------------------------------------------------------------- +// Default export: a convenient namespace object that mirrors the named exports. +// --------------------------------------------------------------------------- +import { defaultConfig, getConfig, resetConfig } from './config.js'; +import { + formatBytes, + parseBytes, + toBytes, + getLargerByte, + getSmallerByte, + isEqualBytes, + diffBytes, + sumBytes, + sortBytes, + analyzeBytes, + isValidByte, + detectUnit, +} from './formatter.js'; + +const bytes = { + formatBytes, + parseBytes, + toBytes, + getLargerByte, + getSmallerByte, + isEqualBytes, + diffBytes, + sumBytes, + sortBytes, + analyzeBytes, + isValidByte, + detectUnit, + defaultConfig, + getConfig, + resetConfig, +}; + +export default bytes; diff --git a/src/types.ts b/src/types.ts index 96cab89..b1d56d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,3 +67,31 @@ export interface ByteStats { average: string; } +/** + * Global configuration that applies to all bytes-kit functions by default. + * Per-call options always override these. + */ +export interface GlobalConfig { + /** + * Default formatting options applied to every function call. + * Any per-call `options` argument will override these. + */ + space?: boolean; + binary?: boolean; + decimalPlaces?: number; + fixedDecimals?: boolean; + locale?: string | boolean; + + /** + * If true (default), functions throw on invalid input — matching the original behavior. + * If false, errors are handled gracefully via `onError` and a safe fallback is returned. + * @default true + */ + throwOnError?: boolean; + + /** + * Called with the Error when `throwOnError` is false and an error occurs. + * If not provided, falls back to `console.error`. + */ + onError?: (error: Error) => void; +} diff --git a/tsconfig.json b/tsconfig.json index 45a1487..9f6e6d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "declaration": true, "strict": true, "esModuleInterop": true, @@ -11,6 +13,11 @@ "forceConsistentCasingInFileNames": true, "outDir": "./dist" }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file From fcc824e952ea498fdcc038b8828f726f86879769 Mon Sep 17 00:00:00 2001 From: sathya Date: Fri, 10 Jul 2026 08:47:25 +0530 Subject: [PATCH 2/2] 1.2.0 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index efcce9c..e93ba67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bytes-kit", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bytes-kit", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "devDependencies": { "tsup": "^8.0.2", diff --git a/package.json b/package.json index 140ed2e..8af66ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bytes-kit", - "version": "1.1.0", + "version": "1.2.0", "description": "Utilities for formatting, parsing, and working with byte sizes.", "type": "module", "main": "./dist/index.cjs", @@ -61,4 +61,4 @@ "type": "github", "url": "https://github.com/sponsors/cool-Dev-master" } -} \ No newline at end of file +}