diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e5ca728 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md + +This file provides guidance to AI coding agents when working with code in this repository. + +## Project Overview + +numeric-quantity is a TypeScript library that converts human-readable numeric strings to numbers — an enhanced `parseFloat`. It handles plain numbers, fractions, mixed numbers, vulgar fraction characters, Roman numerals, and non-ASCII decimal digits from 70+ Unicode scripts. + +## Commands + +- **Build:** `bun run build` (tsdown → ESM, CJS, UMD) +- **Test:** `bun test` (Bun test runner; 100% coverage threshold) +- **Test single file:** `bun test src/index.test.ts` +- **Test watch:** `bun test --watch` +- **Lint:** `bun run lint` (oxlint) +- **Format:** `bun run pretty-print` (Prettier) +- **Type-check:** `tsc` +- **Docs:** `bun run docs` (TypeDoc) + +## Architecture + +All source lives in `src/`. The library exports three public APIs from `src/index.ts`: + +- `numericQuantity()` — core parser (`src/numericQuantity.ts`). Normalizes Unicode digits, strips formatting, then matches against regex patterns for integers, decimals, fractions, mixed numbers, and scientific notation. Returns `NaN` on invalid input. Accepts an options object for Roman numeral parsing and verbose output. +- `isNumericQuantity()` — boolean wrapper (`src/isNumericQuantity.ts`) +- `parseRomanNumerals()` — standalone Roman numeral parser (`src/parseRomanNumerals.ts`) + +Supporting files: +- `src/constants.ts` — regex patterns, Unicode digit range table, default options +- `src/types.ts` — TypeScript types and interfaces + +## Testing + +Tests use `bun:test` (import from `'bun:test'`). Test data fixtures are in `src/numericQuantityTests.ts` — add new test cases there rather than inline in the test file. Coverage must stay at 100%. + +## Code Style + +- 2-space indentation, single quotes, semicolons, ES5 trailing commas +- Arrow parens: avoid when possible +- Prettier handles formatting; oxlint handles linting +- Strict TypeScript (`isolatedModules`, `isolatedDeclarations` enabled) + +## Build Output + +Dual-package ESM + CJS with a UMD bundle. Targets ES2021 (standard) and ES2017 (legacy ESM for Webpack 4). Only `dist/` is published. diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d520a..2f555a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -N/A +### Added + +- [#39] `isNumericQuantity(str, options?)` function for boolean validation without parsing. +- [#39] `percentage` option to parse percentage strings (`"50%"` → `0.5` with `'decimal'`/`true`, or `50` with `'number'`). +- [#39] `allowCurrency` option to strip Unicode currency symbols (`$`, `€`, `£`, `¥`, `₹`, `₿`, etc.) from prefix or suffix. +- [#39] `verbose` option to return a detailed result object with the following fields: + - `value` — the parsed numeric value (`NaN` if invalid). + - `input` — the original input string. + - `currencyPrefix` / `currencySuffix` — currency symbol(s) stripped from start/end, if any. + - `percentageSuffix` — `true` if a `%` suffix was stripped. + - `trailingInvalid` — trailing non-numeric characters detected in the input, if any. Populated regardless of the `allowTrailingInvalid` setting. + - `sign` — the leading sign character (`'-'` or `'+'`), if present. + - `whole` — the whole-number part of a mixed fraction (e.g. `1` from `"1 2/3"`). + - `numerator` / `denominator` — fraction components (e.g. `2` and `3` from `"1 2/3"`). Always unsigned. +- [#39] Leading `+` sign support: `numericQuantity('+42')` now returns `42` instead of `NaN`. Works with all input forms including fractions (`'+1/2'`), mixed numbers (`'+1 1/2'`), decimals (`'+1.5'`), and currency (`'+$100'`). ## [v3.1.0] - 2026-02-11 ### Added -- Support for non-ASCII decimal numeral systems (Arabic-Indic, Devanagari, Bengali, Thai, Fullwidth, and 70+ other Unicode `\p{Nd}` digit blocks). For example, `numericQuantity('٣')` now returns `3`. +- [#38] Support for non-ASCII decimal numeral systems (Arabic-Indic, Devanagari, Bengali, Thai, Fullwidth, and 70+ other Unicode `\p{Nd}` digit blocks). For example, `numericQuantity('٣')` now returns `3`. ## [v3.0.0] - 2026-01-21 @@ -190,6 +204,8 @@ N/A [#12]: https://github.com/jakeboone02/numeric-quantity/pull/12 [#26]: https://github.com/jakeboone02/numeric-quantity/pull/26 [#37]: https://github.com/jakeboone02/numeric-quantity/pull/37 +[#38]: https://github.com/jakeboone02/numeric-quantity/pull/38 +[#39]: https://github.com/jakeboone02/numeric-quantity/pull/39 diff --git a/README.md b/README.md index 6ff7320..09e97a2 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,16 @@ [![downloads](https://img.shields.io/npm/dm/numeric-quantity.svg)](https://npm-stat.com/charts.html?package=numeric-quantity&from=2015-08-01) [![MIT License](https://img.shields.io/npm/l/numeric-quantity.svg)](https://opensource.org/licenses/MIT) -Converts a string to a number, like an enhanced version of `parseFloat`. +Converts a string to a number, like an enhanced version of `parseFloat`. Returns `NaN` if the provided string does not resemble a number. **[Full documentation](https://jakeboone02.github.io/numeric-quantity/)** -Features: +In addition to plain integers and decimals, `numeric-quantity` handles: -- In addition to plain integers and decimals, `numeric-quantity` can parse numbers with comma or underscore separators (`'1,000'` or `'1_000'`), mixed numbers (`'1 2/3'`), vulgar fractions (`'1⅖'`), and the fraction slash character (`'1 2⁄3'`). -- Supports non-ASCII decimal numeral systems including Arabic-Indic (`'٣'`), Devanagari (`'३'`), Bengali (`'৩'`), Thai (`'๓'`), Fullwidth (`'3'`), and 70+ other Unicode digit scripts. -- To allow and ignore trailing invalid characters _à la_ `parseFloat`, pass `{ allowTrailingInvalid: true }` as the second argument. -- To parse Roman numerals like `'MCCXIV'` or `'Ⅻ'`, pass `{ romanNumerals: true }` as the second argument or call `parseRomanNumerals` directly. -- To parse numbers with European-style decimal comma (where `'1,0'` means `1`, not `10`), pass `{ decimalSeparator: ',' }` as the second argument. -- To produce `bigint` values when the input represents an integer that would exceeds the boundaries of `number`, pass `{ bigIntOnOverflow: true }` as the second argument. -- Results will be rounded to three decimal places by default. To avoid rounding, pass `{ round: false }` as the second argument. To round to a different number of decimal places, assign that number to the `round` option (`{ round: 5 }` will round to five decimal places). -- Returns `NaN` if the provided string does not resemble a number. +- **Fractions and mixed numbers**: `'1 2/3'` → `1.667`, `'1⅖'` → `1.4`, `'1 2⁄3'` → `1.667` +- **Separators**: `'1,000'` → `1000`, `'1_000_000'` → `1000000` +- **Roman numerals** (see [option](#roman-numerals-romannumerals) below): `'XIV'` → `14`, `'Ⅻ'` → `12` +- **Non-ASCII numerals**: Arabic-Indic (`'٣'`), Devanagari (`'३'`), Bengali, Thai, Fullwidth, and 70+ other Unicode digit scripts > _For the inverse operation—converting a number to an imperial measurement—check out [format-quantity](https://www.npmjs.com/package/format-quantity)._ @@ -57,12 +53,172 @@ As UMD (all exports are properties of the global object `NumericQuantity`): ## Options -| Option | Type | Default | Description | -| ---------------------- | ----------------- | ------- | ---------------------------------------------------------------------------------------------------- | -| `round` | `number \| false` | `3` | Round the result to a certain number of decimal places. Must be greater than or equal to zero. | -| `allowTrailingInvalid` | `boolean` | `false` | Allow and ignore trailing invalid characters _à la_ `parseFloat`. | -| `romanNumerals` | `boolean` | `false` | Attempt to parse Roman numerals if Arabic numeral parsing fails. | -| `bigIntOnOverflow` | `boolean` | `false` | Generates a `bigint` value if the string represents a valid integer too large for the `number` type. | -| `decimalSeparator` | `',' \| '.'` | `"."` | Specifies which character to treat as the decimal separator. | +All options are passed as the second argument to `numericQuantity` (and `isNumericQuantity`). + +### Rounding (`round`) + +Results are rounded to three decimal places by default. Use the `round` option to change this behavior. + +```js +numericQuantity('1/3'); // 0.333 (default: 3 decimal places) +numericQuantity('1/3', { round: 5 }); // 0.33333 +numericQuantity('1/3', { round: false }); // 0.3333333333333333 +``` + +### Trailing Invalid Characters (`allowTrailingInvalid`) + +By default, strings with trailing non-numeric characters return `NaN`. Set `allowTrailingInvalid: true` to ignore trailing invalid characters, similar to `parseFloat`. + +```js +numericQuantity('100abc'); // NaN +numericQuantity('100abc', { allowTrailingInvalid: true }); // 100 +``` + +### Roman Numerals (`romanNumerals`) + +Parse Roman numerals (ASCII or Unicode) by setting `romanNumerals: true`. You can also use `parseRomanNumerals` directly. + +```js +numericQuantity('MCCXIV', { romanNumerals: true }); // 1214 +numericQuantity('Ⅻ', { romanNumerals: true }); // 12 +numericQuantity('xiv', { romanNumerals: true }); // 14 (case-insensitive) +``` + +### Decimal Separator (`decimalSeparator`) + +For European-style numbers where comma is the decimal separator, set `decimalSeparator: ','`. + +```js +numericQuantity('1,5'); // 15 (comma treated as thousands separator) +numericQuantity('1,5', { decimalSeparator: ',' }); // 1.5 +numericQuantity('1.000,50', { decimalSeparator: ',' }); // 1000.5 +``` + +### Large Integers (`bigIntOnOverflow`) + +When parsing integers that exceed `Number.MAX_SAFE_INTEGER` or are less than `Number.MIN_SAFE_INTEGER`, set `bigIntOnOverflow: true` to return a `bigint` instead. + +```js +numericQuantity('9007199254740992'); // 9007199254740992 (loses precision) +numericQuantity('9007199254740992', { bigIntOnOverflow: true }); // 9007199254740992n +``` + +### Percentages (`percentage`) + +Parse percentage strings by setting the `percentage` option. Use `'decimal'` (or `true`) to divide by 100, or `'number'` to just strip the `%` symbol. + +```js +numericQuantity('50%'); // NaN +numericQuantity('50%', { percentage: true }); // 0.5 +numericQuantity('50%', { percentage: 'decimal' }); // 0.5 +numericQuantity('50%', { percentage: 'number' }); // 50 +numericQuantity('1/2%', { percentage: true }); // 0.005 +``` + +### Currency Symbols (`allowCurrency`) + +Strip currency symbols from the start or end of the string by setting `allowCurrency: true`. Supports all Unicode currency symbols (`$`, `€`, `£`, `¥`, `₹`, `₽`, `₿`, `₩`, etc.). + +```js +numericQuantity('$100'); // NaN +numericQuantity('$100', { allowCurrency: true }); // 100 +numericQuantity('€1.000,50', { allowCurrency: true, decimalSeparator: ',' }); // 1000.5 +numericQuantity('100€', { allowCurrency: true }); // 100 +numericQuantity('-$50', { allowCurrency: true }); // -50 +``` + +### Verbose Output (`verbose`) + +Set `verbose: true` to return a detailed result object instead of just the numeric value. This is useful for understanding what was parsed and stripped. + +```js +numericQuantity('$50%', { + verbose: true, + allowCurrency: true, + percentage: true, +}); +// { +// value: 0.5, +// input: '$50%', +// currencyPrefix: '$', +// percentageSuffix: true +// } + +numericQuantity('100abc', { + verbose: true, + allowTrailingInvalid: true, +}); +// { +// value: 100, +// input: '100abc', +// trailingInvalid: 'abc' +// } +``` + +For fraction and mixed-number inputs, the result also includes parsed fraction components (always unsigned): + +```js +numericQuantity('1 2/3', { verbose: true }); +// { +// value: 1.667, +// input: '1 2/3', +// whole: 1, +// numerator: 2, +// denominator: 3 +// } + +numericQuantity('½', { verbose: true }); +// { +// value: 0.5, +// input: '½', +// numerator: 1, +// denominator: 2 +// } +``` + +The verbose result object has the following shape: + +```ts +interface NumericQuantityVerboseResult { + value: number | bigint; // The parsed value (NaN if invalid) + input: string; // Original input string + currencyPrefix?: string; // Currency symbol(s) stripped from start + currencySuffix?: string; // Currency symbol(s) stripped from end + percentageSuffix?: boolean; // True if "%" was stripped + trailingInvalid?: string; // Characters ignored (if allowTrailingInvalid) + sign?: '-' | '+'; // Leading sign character, if present + whole?: number; // Whole part of a mixed fraction (e.g. 1 from "1 2/3") + numerator?: number; // Fraction numerator (e.g. 2 from "1 2/3") + denominator?: number; // Fraction denominator (e.g. 3 from "1 2/3") +} +``` + +## Additional Exports + +### `isNumericQuantity(str, options?): boolean` + +Returns `true` if the string can be parsed as a valid number, `false` otherwise. Accepts the same options as `numericQuantity`. + +```js +import { isNumericQuantity } from 'numeric-quantity'; + +isNumericQuantity('1 1/2'); // true +isNumericQuantity('abc'); // false +isNumericQuantity('XII', { romanNumerals: true }); // true +isNumericQuantity('$100', { allowCurrency: true }); // true +isNumericQuantity('50%', { percentage: true }); // true +``` + +### `parseRomanNumerals(str): number` + +Parses a string of Roman numerals directly. Returns `NaN` for invalid input. + +```js +import { parseRomanNumerals } from 'numeric-quantity'; + +parseRomanNumerals('MCMXCIX'); // 1999 +parseRomanNumerals('Ⅻ'); // 12 +parseRomanNumerals('invalid'); // NaN +``` [badge-npm]: https://img.shields.io/npm/v/numeric-quantity.svg?cacheSeconds=3600&logo=npm diff --git a/bun.lock b/bun.lock index ae6a804..2253ac2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,9 @@ "": { "name": "numeric-quantity", "devDependencies": { - "@jakeboone02/generate-dts": "0.1.2", "@types/bun": "^1.3.9", "@types/node": "^25.2.3", - "@types/web": "^0.0.331", + "@types/web": "^0.0.332", "@typescript/native-preview": "^7.0.0-dev.20260211.1", "np": "^11.0.2", "oxlint": "^1.46.0", @@ -78,8 +77,6 @@ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], - "@jakeboone02/generate-dts": ["@jakeboone02/generate-dts@0.1.2", "", { "peerDependencies": { "oxc-transform": "^0.63.0" } }, "sha512-G5Jlzz5HiP5WpGkT5luZ2tU/dux/EzUL+0PoR1XmBlmUaAGin/tU0u0MmJUfJOf4VViIR+kQY1Vdd7PB1UplMA=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -88,7 +85,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -98,75 +95,55 @@ "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], - "@oxc-transform/binding-darwin-arm64": ["@oxc-transform/binding-darwin-arm64@0.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZXFMAjIf1/D2lSpfMJribjm5X7TdLYUHqtHsaizsNQjXv111hDUkFvqtQT0uWMMn35VdyrJATGP/MNDCzD3zQg=="], - - "@oxc-transform/binding-darwin-x64": ["@oxc-transform/binding-darwin-x64@0.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-NJvBZcjBZNYXcpCbA+xB8efLbfc9SWAevs8vqJqBy6cO+DDCH1XULkzZm0zpZNE/T+sfUW5m9oYYntJl4hcxng=="], - - "@oxc-transform/binding-linux-arm-gnueabihf": ["@oxc-transform/binding-linux-arm-gnueabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-FFd6qyKvQoHt8OygRqvoS/7jsFmQqu0cjP4GpxRK3wD0JEnNBIWPA9m1cIG8h/0FEy66srvguHrLfDXqB1RTMw=="], - - "@oxc-transform/binding-linux-arm64-gnu": ["@oxc-transform/binding-linux-arm64-gnu@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0TTyD6H6gndPEKIuOh0EyjybScF/i8josFIG0tnZdTOSXJcJ7qxEJtX8+UkrGWVf4aukrwe7tkjnBWiWg2kOxg=="], - - "@oxc-transform/binding-linux-arm64-musl": ["@oxc-transform/binding-linux-arm64-musl@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rD9jm2C8qOTZJLTsIpNWkqcmvbHKpvwYY7oBKNo68l0uaNEviVAbOmhgSMmaSRoZnoaLZyUSs4dL3+jIlgDnmA=="], - - "@oxc-transform/binding-linux-x64-gnu": ["@oxc-transform/binding-linux-x64-gnu@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ViFD67fOShiZb8uGXYHa10JgAJUCoEdiRq6Pkwo8G4/FpRQJiUk247ub6uHD2LzMI2mb77mzNz7hq44fVc+qMw=="], - - "@oxc-transform/binding-linux-x64-musl": ["@oxc-transform/binding-linux-x64-musl@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-TH/qhlBpQrh5rVH4mtFTin508dLztb+m6CKrs0xcJkg7maUeFFP5jwZLAfcgO6fyIwqZDAGh6xbKkatP+T2GKQ=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.12.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-V5xXFGggPyzVySV9cgUi0NLCQJ/GBl4Whd96dadyiu5bmEKMclN1tFdJ870R69TonuTDG5IQLe3L95c53erYWQ=="], - "@oxc-transform/binding-wasm32-wasi": ["@oxc-transform/binding-wasm32-wasi@0.63.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.8" }, "cpu": "none" }, "sha512-cjrNWd1pFZm0CmJVZ8AZ+CNWJBaQxQMuo9lbxuv9W4lo0gr0YI8zXYg8nzVo3GFuxrYyJWQs9Qy6FhXGEgmiJg=="], + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.12.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-UbgHnbf8Pd0/Ceo0yJfY4z5x0vnCVAeqXA/wlTom1oHSeNl1OXnW628k4o5B4MJrEwIkUR/4HMPvEV/XG7XIHA=="], - "@oxc-transform/binding-win32-arm64-msvc": ["@oxc-transform/binding-win32-arm64-msvc@0.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KOtdkayxoUdAdDWCuzIECp2V5+aampT7y3FMyJ2pfRksLsfKZi/uv0sh/iGD8mbg9XFpt+NeySF+xDwu/VwKLg=="], + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.12.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OQj1qGnbPd4WYcaPuOvYvt+UahA1sNtr7owFlzYtNafycAs2umMOr89h6OAJyFfjdmCukIwT4DZJefKl96cxBA=="], - "@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+Jg5/09KWhUEX/OBi266FxnJ2uHwVi9BJPzZffqXOHwb3OLrDo/EghIGbqvxU/xVVizV4h0AztdFXg9FzneBRw=="], + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.12.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NBl6yQeOT93/EyggOTn/QADJl1oPubMkm82SHFEHbQX+XCD3VhDEtjCPaja1crjGec8lbymq72mpNxumsBLARg=="], - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.12.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0tY8yjj6EZUIaz4OOp/a7qonh0HioLsLTVRFOky1RouELUj95pSlVdIM0e8554csmJ2PsDXGfBCiYOiDVYrYDQ=="], + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.12.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-MlChwWQ3xQjcWJI1KnxiTPicGblstfMOAnGfsRa30HMXtwb+gpnq/zWhKpOFx4VsYAXPofCTGQEM7HolK/k4uw=="], - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.12.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-2KvHdh56XsvsUQNH0/wLegYjKisjgMZqSsk0s3S5h79+EYBl/X1XGgle2zaiyTsgLXIYyabDBku4jXBY2AfmkA=="], + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.12.1", "", { "os": "win32", "cpu": "x64" }, "sha512-1y1PywzZ5UBIb+GWvcHoaTZ4t0Ae5qGlgtpCKrynl9TfQ92JTHvD+04dceG4Ih/y0YH0ZNkdFFxKbMvt4kHr2w=="], - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.12.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oV8YIrmqkw2/oV89XA0wJ63hw1IfohyoF0Or2hjBb1HZpZNj1SrtFC1K4ikIcjPwLJ43FH4Rhacb//S3qx5zbQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.47.0", "", { "os": "android", "cpu": "arm" }, "sha512-UHqo3te9K/fh29brCuQdHjN+kfpIi9cnTPABuD5S9wb9ykXYRGTOOMVuSV/CK43sOhU4wwb2nT1RVjcbrrQjFw=="], - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.12.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9t4IUPeq3+TQPL6W7HkYaEYpsYO+SUqdB+MPqIjwWbF+30I2/RPu37aclZq/J3Ybic+eMbWTtodPAIu5Gjq+kg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.47.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xh02lsTF1TAkR+SZrRMYHR/xCx8Wg2MAHxJNdHVpAKELh9/yE9h4LJeqAOBbIb3YYn8o/D97U9VmkvkfJfrHfw=="], - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.12.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-HdtDsqH+KdOy/7Mod9UJIjgRM6XjyOgFEbp1jW7AjMWzLjQgMvSF/tTphaLqb4vnRIIDU8Y3Or8EYDCek/++bA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OSOfNJqabOYbkyQDGT5pdoL+05qgyrmlQrvtCO58M4iKGEQ/xf3XkkKj7ws+hO+k8Y4VF4zGlBsJlwqy7qBcHA=="], - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.12.0", "", { "os": "win32", "cpu": "x64" }, "sha512-f0tXGQb/qgvLM/UbjHzia+R4jBoG6rQp1SvnaEjpDtn8OSr2rn0IhqdpeBEtIUnUeSXcTFR0iEqJb39soP6r0A=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hP2bOI4IWNS+F6pVXWtRshSTuJ1qCRZgDgVUg6EBUqsRy+ExkEPJkx+YmIuxgdCduYK1LKptLNFuQLJP8voPbQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.46.0", "", { "os": "android", "cpu": "arm" }, "sha512-vLPcE+HcZ/W/0cVA1KLuAnoUSejGougDH/fDjBFf0Q+rbBIyBNLevOhgx3AnBNAt3hcIGY7U05ISbJCKZeVa3w=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.47.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-F55jIEH5xmGu7S661Uho8vGiLFk0bY3A/g4J8CTKiLJnYu/PSMZ2WxFoy5Hji6qvFuujrrM9Q8XXbMO0fKOYPg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.46.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b8IqCczUsirdtJ3R/be4cRm64I5pMPafMO/9xyTAZvc+R/FxZHMQuhw0iNT9hQwRn+Uo5rNAoA8QS7QurG2QeA=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wxmOn/wns/WKPXUC1fo5mu9pMZPVOu8hsynaVDrgmmXMdHKS7on6bA5cPauFFN9tJXNdsjW26AK9lpfu3IfHBQ=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.46.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CfC/KGnNMhI01dkfCMjquKnW4zby3kqD5o/9XA7+pgo9I4b+Nipm+JVFyZPWMNwKqLXNmi35GTLWjs9svPxlew=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KJTmVIA/GqRlM2K+ZROH30VMdydEU7bDTY35fNg3tOPzQRIs2deLZlY/9JWwdWo1F/9mIYmpbdCmPqtKhWNOPg=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.46.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-m38mKPsV3rBdWOJ4TAGZiUjWU8RGrBxsmdSeMQ0bPr/8O6CUOm/RJkPBf0GAfPms2WRVcbkfEXvIiPshAeFkeA=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PF7ELcFg1GVlS0X0ZB6aWiXobjLrAKer3T8YEkwIoO8RwWiAMkL3n3gbleg895BuZkHVlJ2kPRUwfrhHrVkD1A=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.46.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YaFRKslSAfuMwn7ejS1/wo9jENqQigpGBjjThX+mrpmEROLYGky+zIC5xSVGRng28U92VEDVbSNJ/sguz3dUAA=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4BezLRO5cu0asf0Jp1gkrnn2OHiXrPPPEfBTxq1k5/yJ2zdGGTmZxHD2KF2voR23wb8Elyu3iQawXo7wvIZq0Q=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Nlw+5mSZQtkg1Oj0N8ulxzG8ATpmSDz5V2DNaGhaYAVlcdR8NYSm/xTOnweOXc/UOOv3LwkPPYzqcfPhu2lEkA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.47.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aI5ds9jq2CPDOvjeapiIj48T/vlWp+f4prkxs+FVzrmVN9BWIj0eqeJ/hV8WgXg79HVMIz9PU6deI2ki09bR1w=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d3Y5y4ukMqAGnWLMKpwqj8ftNUaac7pA0NrId4AZ77JvHzezmxEcm2gswaBw2HW8y1pnq6KDB0vEPPvpTfDLrA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-mO7ycp9Elvgt5EdGkQHCwJA6878xvo9tk+vlMfT1qg++UjvOMB8INsOCQIOH2IKErF/8/P21LULkdIrocMw9xA=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jkjx+XSOPuFR+C458prQmehO+v0VK19/3Hj2mOYDF4hHUf3CzmtA4fTmQUtkITZiGHnky7Oao6JeJX24mrX7WQ=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-24D0wsYT/7hDFn3Ow32m3/+QT/1ZwrUhShx4/wRDAmz11GQHOZ1k+/HBuK/MflebdnalmXWITcPEy4BWTi7TCA=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-X/aPB1rpJUdykjWSeeGIbjk6qbD8VDulgLuTSMWgr/t6m1ljcAjqHb1g49pVG9bZl55zjECgzvlpPLWnfb4FMQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.47.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8tPzPne882mtML/uy3mApvdCyuVOpthJ7xUv3b67gVfz63hOOM/bwO0cysSkPyYYFDFRn6/FnUb7Jhmsesntvg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.46.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AymyOxGWwKY2KJa8b+h8iLrYJZbWKYCjqctSc2q6uIAkYPrCsxcWlge1JP6XZ14Sa80DVMwI/QvktbytSV+xVw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-q58pIyGIzeffEBhEgbRxLFHmHfV9m7g1RnkLiahQuEvyjKNiJcvdHOwKH2BdgZxdzc99Cs6hF5xTa86X40WzPw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-PkeVdPKCDA59rlMuucsel2LjlNEpslQN5AhkMMorIJZItbbqi/0JSuACCzaiIcXYv0oNfbeQ8rbOBikv+aT6cg=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-e7DiLZtETZUCwTa4EEHg9G+7g3pY+afCWXvSeMG7m0TQ29UHHxMARPaEQUE4mfKgSqIWnJaUk2iZzRPMRdga5g=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-snQaRLO/X+Ry/CxX1px1g8GUbmXzymdRs+/RkP2bySHWZFhFDtbLm2hA1ujX/jKlTLMJDZn4hYzFGLDwG/Rh2w=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.47.0", "", { "os": "none", "cpu": "arm64" }, "sha512-3AFPfQ0WKMleT/bKd7zsks3xoawtZA6E/wKf0DjwysH7wUiMMJkNKXOzYq1R/00G98JFgSU1AkrlOQrSdNNhlg=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.46.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-kZhDMwUe/sgDTluGao9c0Dqc1JzV6wPzfGo0l/FLQdh5Zmp39Yg1FbBsCgsJfVKmKl1fNqsHyFLTShWMOlOEhA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-cLMVVM6TBxp+N7FldQJ2GQnkcLYEPGgiuEaXdvhgvSgODBk9ov3jed+khIXSAWtnFOW0wOnG3RjwqPh0rCuheA=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-n5a7VtQTxHZ13cNAKQc3ziARv5bE1Fx868v/tnhZNVUjaRNYe5uiUrRJ/LZghdAzOxVuQGarjjq/q4QM2+9OPA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.47.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-VpFOSzvTnld77/Edje3ZdHgZWnlTb5nVWXyTgjD3/DKF/6t5bRRbwn3z77zOdnGy44xAMvbyAwDNOSeOdVUmRA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KpsDU/BhdVn3iKCLxMXAOZIpO8fS0jEA5iluRoK1rhHPwKtpzEm/OCwERsu/vboMSZm66qnoTUVXRPJ8M+iKVQ=="], - - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.46.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jtbqUyEXlsDlRmMtTZqNbw49+1V/WxqNAR5l0S3OEkdat9diI5I+eqq9IT+jb5cSDdszTGcXpn7S3+gUYSydxQ=="], - - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.46.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-EE8NjpqEZPwHQVigNvdyJ11dZwWIfsfn4VeBAuiJeAdrnY4HFX27mIjJINJgP5ZdBYEFV1OWH/eb9fURCYel8w=="], - - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.46.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-BHyk3H/HRdXs+uImGZ/2+qCET+B8lwGHOm7m54JiJEEUWf3zYCFX/Df1SPqtozWWmnBvioxoTG1J3mPRAr8KUA=="], - - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+q8IWptxXx2HMTM6JluR67284t0h8X/oHJgqpxH1siowxPMqZeIpAcWCUq+tY+Rv2iQK8TUugjZnSBQAVV5CmA=="], "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], @@ -236,23 +213,23 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/web": ["@types/web@0.0.331", "", {}, "sha512-42GksRe3NF9duzArn75JnTtXQNS6vUHMK0eIRZAIjRSaLb1Txu7xNlIhkNycx8o2rNGsTnajClJnAMVSfAN67Q=="], + "@types/web": ["@types/web@0.0.332", "", {}, "sha512-tQJAUSeZ3jUX8Ci3g05cGckKrS3k834FImYzh5nf+sOu9REWoj3XJu3TcTWYWSi3RXDreHFNeAo0WeoQUwAOZA=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260211.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260211.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260211.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260211.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260211.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260211.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260211.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260211.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-6chHuRpRMTFuSnlGdm+L72q3PBcsH/Tm4KZpCe90T+0CPbJZVewNGEl3PNOqsLBv9LYni4kVTgVXpYNzKXJA5g=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260212.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260212.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260212.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-VHAVbp8d2VGm90EK//brKIYvT3iPrLXMq4/LApCdkKww/Hfn33zPRVmig4rswNaJiVu8XhcdHld5yfMw6d5A9Q=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260211.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xRuGrUMmC8/CapuCdlIT/Iw3xq9UQAH2vjReHA3eE4zkK5VLRNOEJFpXduBwBOwTaxfhAZl74Ht0eNg/PwSqVA=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260212.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HH4bOVbNW6ITv00VSaE3aZjCuU2d+amgFZKdhbq7NpcJDxFvxyy9GT9gkKV0D1DXz5qoxZIcyBEIbwrhABb9vg=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260211.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYbpbt395w8YZgNotEZQxBoa9p7xHDhK3TH2xCV8pZf5GVsBqi76NHAS1EXiJ3njmmx7OdyPPNjCNfdmQkAgqg=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260212.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-vnQ2xRJscbtyS/jHO5QY2xAZ3c11Yn1ZAor/XODDrxd7N7jIrm0Vtc2CIwsi51oncLS1SZtUd9cHZmJg5zUJrQ=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260211.1", "", { "os": "linux", "cpu": "arm" }, "sha512-v72/IFGifEyt5ZFjmX5G4cnCL2JU2kXnfpJ/9HS7FJFTjvY6mT2mnahTq/emVXf+5y4ee7vRLukQP5bPJqiaWQ=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "arm" }, "sha512-T8sF3YtYtODhWnFNhVuL/GABCHpKJs6ZxTtSC1LtXoM/CE0Ai06k5WKOxJG5rJrBtLIW+Dempk7qKPfhNliDTA=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260211.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-10rfJdz5wxaCh643qaQJkPVF500eCX3HWHyTXaA2bifSHZzeyjYzFL5EOzNKZuurGofJYPWXDXmmBOBX4au8rA=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-suA5OryrhL/tE7AiQXiNNV88XwKEOfO0sypJQj+cfg/fpQ2trFyDZcsdMLYVZ7J0zirDai6H3TDETYYoNFE1/g=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260211.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xpJ1KFvMXklzpqpysrzwlDhhFYJnXZyaubyX3xLPO0Ct9Beuf9TzYa1tzO4+cllQB6aSQ1PgPIVbbzB+B5Gfsw=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "x64" }, "sha512-w687rpZKJM0Lev0ya0GYJlnFCITTUmN8jDpwLXn60jrNEZzL2J4F7biA6papr2sMdKRfWvRklhjB1TKHbJ6FKA=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260211.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-ccqtRDV76NTLZ1lWrYBPom2b0+4c5CWfG5jXLcZVkei5/DUKScV7/dpQYcoQMNekGppj8IerdAw4G3FlDcOU7w=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260212.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-NhCXPQF6OTNEZl8iwRE1ef/zHiqit5p3m7hdT2vfAOi1iA2eoazX0zTSdhgjX83o9cLjen3V1R7nbSYehFHaqw=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260211.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ZGMsSiNUuBEP4gKfuxBPuXj0ebSVS51hYy8fbYldluZvPTiphhOBkSm911h89HYXhTK/1P4x00n58eKd0JL7zQ=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260212.1", "", { "os": "win32", "cpu": "x64" }, "sha512-0yqSBlASRx9rqM12QvaWc227w+bIsuI2EwAiNsoB1ybRbCXoXMah1RQlfjjTpD02eWCe/029vwrNhq+FLn7Z8A=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], @@ -308,7 +285,7 @@ "clipboard-image": ["clipboard-image@0.1.0", "", { "dependencies": { "run-jxa": "^3.0.0" }, "bin": { "clipboard-image": "cli.js" } }, "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg=="], - "clipboardy": ["clipboardy@5.2.1", "", { "dependencies": { "clipboard-image": "^0.1.0", "execa": "^9.6.1", "is-wayland": "^0.1.0", "is-wsl": "^3.1.0", "is64bit": "^2.0.0", "powershell-utils": "^0.2.0" } }, "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw=="], + "clipboardy": ["clipboardy@5.3.0", "", { "dependencies": { "clipboard-image": "^0.1.0", "execa": "^9.6.1", "is-wayland": "^0.1.0", "is-wsl": "^3.1.0", "is64bit": "^2.0.0", "powershell-utils": "^0.2.0" } }, "sha512-EOei1RJTbqXbXhUBMGN8C/Pf+QIPrnDWx9ztlmcW5Hljqj/oVlPrlrDw2O4xh5ViHcvHX3+A0zBrCdcptKTaJA=="], "code-point-at": ["code-point-at@1.1.0", "", {}, "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA=="], @@ -572,11 +549,9 @@ "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], - "oxc-transform": ["oxc-transform@0.63.0", "", { "optionalDependencies": { "@oxc-transform/binding-darwin-arm64": "0.63.0", "@oxc-transform/binding-darwin-x64": "0.63.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.63.0", "@oxc-transform/binding-linux-arm64-gnu": "0.63.0", "@oxc-transform/binding-linux-arm64-musl": "0.63.0", "@oxc-transform/binding-linux-x64-gnu": "0.63.0", "@oxc-transform/binding-linux-x64-musl": "0.63.0", "@oxc-transform/binding-wasm32-wasi": "0.63.0", "@oxc-transform/binding-win32-arm64-msvc": "0.63.0", "@oxc-transform/binding-win32-x64-msvc": "0.63.0" } }, "sha512-lmuGJ6pCDUSJ5iEF+P/GuMett0TRhwDl96vQxWRT5l3YqmgmO/idXYOgusxDOvxxx5eFi0OiZ6+9bWKf5XNZdQ=="], - - "oxlint": ["oxlint@1.46.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.46.0", "@oxlint/binding-android-arm64": "1.46.0", "@oxlint/binding-darwin-arm64": "1.46.0", "@oxlint/binding-darwin-x64": "1.46.0", "@oxlint/binding-freebsd-x64": "1.46.0", "@oxlint/binding-linux-arm-gnueabihf": "1.46.0", "@oxlint/binding-linux-arm-musleabihf": "1.46.0", "@oxlint/binding-linux-arm64-gnu": "1.46.0", "@oxlint/binding-linux-arm64-musl": "1.46.0", "@oxlint/binding-linux-ppc64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-musl": "1.46.0", "@oxlint/binding-linux-s390x-gnu": "1.46.0", "@oxlint/binding-linux-x64-gnu": "1.46.0", "@oxlint/binding-linux-x64-musl": "1.46.0", "@oxlint/binding-openharmony-arm64": "1.46.0", "@oxlint/binding-win32-arm64-msvc": "1.46.0", "@oxlint/binding-win32-ia32-msvc": "1.46.0", "@oxlint/binding-win32-x64-msvc": "1.46.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-I9h42QDtAVsRwoueJ4PL/7qN5jFzIUXvbO4Z5ddtII92ZCiD7uiS/JW2V4viBSfGLsbZkQp3YEs6Ls4I8q+8tA=="], + "oxlint": ["oxlint@1.47.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.47.0", "@oxlint/binding-android-arm64": "1.47.0", "@oxlint/binding-darwin-arm64": "1.47.0", "@oxlint/binding-darwin-x64": "1.47.0", "@oxlint/binding-freebsd-x64": "1.47.0", "@oxlint/binding-linux-arm-gnueabihf": "1.47.0", "@oxlint/binding-linux-arm-musleabihf": "1.47.0", "@oxlint/binding-linux-arm64-gnu": "1.47.0", "@oxlint/binding-linux-arm64-musl": "1.47.0", "@oxlint/binding-linux-ppc64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-musl": "1.47.0", "@oxlint/binding-linux-s390x-gnu": "1.47.0", "@oxlint/binding-linux-x64-gnu": "1.47.0", "@oxlint/binding-linux-x64-musl": "1.47.0", "@oxlint/binding-openharmony-arm64": "1.47.0", "@oxlint/binding-win32-arm64-msvc": "1.47.0", "@oxlint/binding-win32-ia32-msvc": "1.47.0", "@oxlint/binding-win32-x64-msvc": "1.47.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-v7xkK1iv1qdvTxJGclM97QzN8hHs5816AneFAQ0NGji1BMUquhiDAhXpMwp8+ls16uRVJtzVHxP9pAAXblDeGA=="], - "oxlint-tsgolint": ["oxlint-tsgolint@0.12.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.12.0", "@oxlint-tsgolint/darwin-x64": "0.12.0", "@oxlint-tsgolint/linux-arm64": "0.12.0", "@oxlint-tsgolint/linux-x64": "0.12.0", "@oxlint-tsgolint/win32-arm64": "0.12.0", "@oxlint-tsgolint/win32-x64": "0.12.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-Ab8Ztp5fwHuh+UFUOhrNx6iiTEgWRYSXXmli1QuFId22gEa7TB0nEdZ7Rrp1wr7SNXuWupJlYYk3FB9JNmW9tA=="], + "oxlint-tsgolint": ["oxlint-tsgolint@0.12.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.12.1", "@oxlint-tsgolint/darwin-x64": "0.12.1", "@oxlint-tsgolint/linux-arm64": "0.12.1", "@oxlint-tsgolint/linux-x64": "0.12.1", "@oxlint-tsgolint/win32-arm64": "0.12.1", "@oxlint-tsgolint/win32-x64": "0.12.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-2Od1S2pA+VkfIlmvHmDwMfhfHyL0jR6JAkP4BkoAidUqYJS1cY2JoLd4uMWcG4mhCQrPYIcEz56VrQ9qUVcoXw=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -780,8 +755,6 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], - "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "boxen/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/package.json b/package.json index fd9b111..602ff5d 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,16 @@ "./package.json": "./package.json", ".": { "import": { - "types": "./dist/types-esm/index.d.mts", + "types": "./dist/numeric-quantity.d.mts", "default": "./dist/numeric-quantity.mjs" }, "require": { - "types": "./dist/types/index.d.ts", + "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" } } }, - "types": "./dist/types/index.d.ts", + "types": "./dist/numeric-quantity.legacy-esm.d.ts", "unpkg": "./dist/numeric-quantity.umd.min.js", "bugs": { "url": "https://github.com/jakeboone02/numeric-quantity/issues" @@ -53,10 +53,9 @@ "pretty-print": "prettier --write '*.{html,json,ts}' 'src/*.*'" }, "devDependencies": { - "@jakeboone02/generate-dts": "0.1.2", "@types/bun": "^1.3.9", "@types/node": "^25.2.3", - "@types/web": "^0.0.331", + "@types/web": "^0.0.332", "@typescript/native-preview": "^7.0.0-dev.20260211.1", "np": "^11.0.2", "oxlint": "^1.46.0", diff --git a/src/constants.ts b/src/constants.ts index 4f14b2d..87504e5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -156,7 +156,7 @@ export const vulgarFractionToAsciiMap: Record< * | # | Description | Example(s) | * | --- | ------------------------------------------------ | ------------------------------------------------------------------- | * | `0` | entire string | `"2 1/3"` from `"2 1/3"` | - * | `1` | "negative" dash | `"-"` from `"-2 1/3"` | + * | `1` | sign (`-` or `+`) | `"-"` from `"-2 1/3"` | * | `2` | whole number or numerator | `"2"` from `"2 1/3"`; `"1"` from `"1/3"` | * | `3` | entire fraction, decimal portion, or denominator | `" 1/3"` from `"2 1/3"`; `".33"` from `"2.33"`; `"/3"` from `"1/3"` | * @@ -173,12 +173,13 @@ export const vulgarFractionToAsciiMap: Record< * ``` */ export const numericRegex: RegExp = - /^(?=-?\s*\.\d|-?\s*\d)(-)?\s*((?:\d(?:[,_]\d|\d)*)*)(([eE][+-]?\d(?:[,_]\d|\d)*)?|\.\d(?:[,_]\d|\d)*([eE][+-]?\d(?:[,_]\d|\d)*)?|(\s+\d(?:[,_]\d|\d)*\s*)?\s*\/\s*\d(?:[,_]\d|\d)*)?$/; + /^(?=[-+]?\s*\.\d|[-+]?\s*\d)([-+])?\s*((?:\d(?:[,_]\d|\d)*)*)(([eE][+-]?\d(?:[,_]\d|\d)*)?|\.\d(?:[,_]\d|\d)*([eE][+-]?\d(?:[,_]\d|\d)*)?|(\s+\d(?:[,_]\d|\d)*\s*)?\s*\/\s*\d(?:[,_]\d|\d)*)?$/; /** * Same as {@link numericRegex}, but allows (and ignores) trailing invalid characters. + * Capture group 7 contains the trailing invalid portion. */ export const numericRegexWithTrailingInvalid: RegExp = - /^(?=-?\s*\.\d|-?\s*\d)(-)?\s*((?:\d(?:[,_]\d|\d)*)*)(([eE][+-]?\d(?:[,_]\d|\d)*)?|\.\d(?:[,_]\d|\d)*([eE][+-]?\d(?:[,_]\d|\d)*)?|(\s+\d(?:[,_]\d|\d)*\s*)?\s*\/\s*\d(?:[,_]\d|\d)*)?(?:\s*[^.\d/].*)?/; + /^(?=[-+]?\s*\.\d|[-+]?\s*\d)([-+])?\s*((?:\d(?:[,_]\d|\d)*)*)(([eE][+-]?\d(?:[,_]\d|\d)*)?|\.\d(?:[,_]\d|\d)*([eE][+-]?\d(?:[,_]\d|\d)*)?|(\s+\d(?:[,_]\d|\d)*\s*)?\s*\/\s*\d(?:[,_]\d|\d)*)?(\s*[^.\d/].*)?/; /** * Captures any Unicode vulgar fractions. @@ -349,4 +350,7 @@ export const defaultOptions: Required = { romanNumerals: false, bigIntOnOverflow: false, decimalSeparator: '.', + allowCurrency: false, + percentage: false, + verbose: false, } as const; diff --git a/src/index.test.ts b/src/index.test.ts index 36f35b7..4262fb4 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, Matchers, test } from 'bun:test'; import { normalizeDigits } from './constants'; +import { isNumericQuantity } from './isNumericQuantity'; import { numericQuantity } from './numericQuantity'; import { numericQuantityTests } from './numericQuantityTests'; @@ -42,3 +43,194 @@ for (const [title, tests] of Object.entries(numericQuantityTests)) { } }); } + +describe('isNumericQuantity', () => { + test('returns true for valid numbers', () => { + expect(isNumericQuantity('123')).toBe(true); + expect(isNumericQuantity('1.5')).toBe(true); + expect(isNumericQuantity('1/2')).toBe(true); + expect(isNumericQuantity('1 1/2')).toBe(true); + expect(isNumericQuantity('½')).toBe(true); + expect(isNumericQuantity(42)).toBe(true); + expect(isNumericQuantity('+42')).toBe(true); + }); + + test('returns false for invalid numbers', () => { + expect(isNumericQuantity('NaN')).toBe(false); + expect(isNumericQuantity('')).toBe(false); + expect(isNumericQuantity('abc')).toBe(false); + expect(isNumericQuantity('$100')).toBe(false); + }); + + test('respects options', () => { + expect(isNumericQuantity('XII')).toBe(false); + expect(isNumericQuantity('XII', { romanNumerals: true })).toBe(true); + expect(isNumericQuantity('$100', { allowCurrency: true })).toBe(true); + expect(isNumericQuantity('50%', { percentage: true })).toBe(true); + }); + + test('returns true for bigint values', () => { + expect( + isNumericQuantity('9007199254740992', { bigIntOnOverflow: true }) + ).toBe(true); + }); +}); + +describe('verbose output', () => { + test('returns object with value and input', () => { + const result = numericQuantity('123', { verbose: true }); + expect(result).toEqual({ value: 123, input: '123' }); + }); + + test('includes currencyPrefix when currency stripped from start', () => { + const result = numericQuantity('$100', { + verbose: true, + allowCurrency: true, + }); + expect(result.value).toBe(100); + expect(result.currencyPrefix).toBe('$'); + }); + + test('includes currencySuffix when currency stripped from end', () => { + const result = numericQuantity('100€', { + verbose: true, + allowCurrency: true, + }); + expect(result.value).toBe(100); + expect(result.currencySuffix).toBe('€'); + }); + + test('includes percentageSuffix when % stripped', () => { + const result = numericQuantity('50%', { + verbose: true, + percentage: 'decimal', + }); + expect(result.value).toBe(0.5); + expect(result.percentageSuffix).toBe(true); + }); + + test('includes trailingInvalid when trailing chars ignored', () => { + const result = numericQuantity('100abc', { + verbose: true, + allowTrailingInvalid: true, + }); + expect(result.value).toBe(100); + expect(result.trailingInvalid).toBe('abc'); + }); + + test('includes trailingInvalid even when allowTrailingInvalid is false', () => { + const result = numericQuantity('100abc', { + verbose: true, + }); + expect(result.value).toBeNaN(); + expect(result.input).toBe('100abc'); + expect(result.trailingInvalid).toBe('abc'); + }); + + test('handles combined currency and percentage', () => { + const result = numericQuantity('$50%', { + verbose: true, + allowCurrency: true, + percentage: 'decimal', + }); + expect(result.value).toBe(0.5); + expect(result.currencyPrefix).toBe('$'); + expect(result.percentageSuffix).toBe(true); + }); + + test('returns NaN value for invalid input', () => { + const result = numericQuantity('invalid', { + verbose: true, + }); + expect(result.value).toBeNaN(); + expect(result.input).toBe('invalid'); + }); + + test('works with number input', () => { + const result = numericQuantity(42, { verbose: true }); + expect(result).toEqual({ value: 42, input: '42' }); + }); + + test('works with bigIntOnOverflow', () => { + const result = numericQuantity('9007199254740992', { + verbose: true, + bigIntOnOverflow: true, + }); + expect(result.value).toBe(9007199254740992n); + expect(result.input).toBe('9007199254740992'); + }); + + test('includes fraction components for mixed fraction', () => { + const result = numericQuantity('1 2/3', { verbose: true }); + expect(result.value).toBe(1.667); + expect(result.whole).toBe(1); + expect(result.numerator).toBe(2); + expect(result.denominator).toBe(3); + }); + + test('includes fraction components for pure fraction', () => { + const result = numericQuantity('2/3', { verbose: true }); + expect(result.value).toBe(0.667); + expect(result.whole).toBeUndefined(); + expect(result.numerator).toBe(2); + expect(result.denominator).toBe(3); + }); + + test('includes fraction components for vulgar fraction', () => { + const result = numericQuantity('½', { verbose: true }); + expect(result.value).toBe(0.5); + expect(result.whole).toBeUndefined(); + expect(result.numerator).toBe(1); + expect(result.denominator).toBe(2); + }); + + test('includes fraction components for mixed vulgar fraction', () => { + const result = numericQuantity('2½', { verbose: true }); + expect(result.value).toBe(2.5); + expect(result.whole).toBe(2); + expect(result.numerator).toBe(1); + expect(result.denominator).toBe(2); + }); + + test('omits fraction components for integer', () => { + const result = numericQuantity('42', { verbose: true }); + expect(result.value).toBe(42); + expect(result.whole).toBeUndefined(); + expect(result.numerator).toBeUndefined(); + expect(result.denominator).toBeUndefined(); + }); + + test('omits fraction components for decimal', () => { + const result = numericQuantity('1.5', { verbose: true }); + expect(result.value).toBe(1.5); + expect(result.whole).toBeUndefined(); + expect(result.numerator).toBeUndefined(); + expect(result.denominator).toBeUndefined(); + }); + + test('fraction components are unsigned for negative mixed fraction', () => { + const result = numericQuantity('-1 2/3', { verbose: true }); + expect(result.value).toBe(-1.667); + expect(result.whole).toBe(1); + expect(result.numerator).toBe(2); + expect(result.denominator).toBe(3); + }); + + test('includes sign for negative input', () => { + const result = numericQuantity('-42', { verbose: true }); + expect(result.value).toBe(-42); + expect(result.sign).toBe('-'); + }); + + test('includes sign for positive input with +', () => { + const result = numericQuantity('+42', { verbose: true }); + expect(result.value).toBe(42); + expect(result.sign).toBe('+'); + }); + + test('omits sign when no explicit sign', () => { + const result = numericQuantity('42', { verbose: true }); + expect(result.value).toBe(42); + expect(result.sign).toBeUndefined(); + }); +}); diff --git a/src/index.ts b/src/index.ts index ad9692f..f996690 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './constants'; +export * from './isNumericQuantity'; export * from './numericQuantity'; export * from './parseRomanNumerals'; export * from './types'; diff --git a/src/isNumericQuantity.ts b/src/isNumericQuantity.ts new file mode 100644 index 0000000..29ae6fe --- /dev/null +++ b/src/isNumericQuantity.ts @@ -0,0 +1,16 @@ +import { numericQuantity } from './numericQuantity'; +import type { NumericQuantityOptions } from './types'; + +/** + * Checks if a string represents a valid numeric quantity. + * + * Returns `true` if the string can be parsed as a number, `false` otherwise. + * Accepts the same options as `numericQuantity`. + */ +export const isNumericQuantity = ( + quantity: string | number, + options?: NumericQuantityOptions +): boolean => { + const result = numericQuantity(quantity, { ...options, verbose: false }); + return typeof result === 'bigint' || !isNaN(result); +}; diff --git a/src/numericQuantity.ts b/src/numericQuantity.ts index d368bfc..5a6c430 100644 --- a/src/numericQuantity.ts +++ b/src/numericQuantity.ts @@ -1,15 +1,21 @@ import { defaultOptions, normalizeDigits, - numericRegex, numericRegexWithTrailingInvalid, vulgarFractionToAsciiMap, vulgarFractionsRegex, } from './constants'; import { parseRomanNumerals } from './parseRomanNumerals'; -import type { NumericQuantityOptions } from './types'; +import type { + NumericQuantityOptions, + NumericQuantityReturnType, + NumericQuantityVerboseResult, +} from './types'; const spaceThenSlashRegex = /^\s*\//; +const currencyPrefixRegex = /^([-+]?)\s*(\p{Sc}+)\s*/u; +const currencySuffixRegex = /\s*(\p{Sc}+)$/u; +const percentageSuffixRegex = /%$/; /** * Converts a string to a number, like an enhanced version of `parseFloat`. @@ -17,10 +23,10 @@ const spaceThenSlashRegex = /^\s*\//; * The string can include mixed numbers, vulgar fractions, or Roman numerals. */ function numericQuantity(quantity: string | number): number; -function numericQuantity( +function numericQuantity( quantity: string | number, - options: NumericQuantityOptions & { bigIntOnOverflow: true } -): number | bigint; + options: T +): NumericQuantityReturnType; function numericQuantity( quantity: string | number, options?: NumericQuantityOptions @@ -28,16 +34,77 @@ function numericQuantity( function numericQuantity( quantity: string | number, options: NumericQuantityOptions = defaultOptions -) { +): number | bigint | NumericQuantityVerboseResult { + const opts: Required = { + ...defaultOptions, + ...options, + }; + + // Metadata for verbose output + const originalInput = typeof quantity === 'string' ? quantity : `${quantity}`; + let currencyPrefix: string | undefined; + let currencySuffix: string | undefined; + let percentageSuffix: boolean | undefined; + let trailingInvalid: string | undefined; + let parsedSign: '-' | '+' | undefined; + let parsedWhole: number | undefined; + let parsedNumerator: number | undefined; + let parsedDenominator: number | undefined; + + const buildVerboseResult = ( + value: number | bigint + ): NumericQuantityVerboseResult => { + const result: NumericQuantityVerboseResult = { value, input: originalInput }; + if (currencyPrefix) result.currencyPrefix = currencyPrefix; + if (currencySuffix) result.currencySuffix = currencySuffix; + if (percentageSuffix) result.percentageSuffix = percentageSuffix; + if (trailingInvalid) result.trailingInvalid = trailingInvalid; + if (parsedSign) result.sign = parsedSign; + if (parsedWhole !== undefined) result.whole = parsedWhole; + if (parsedNumerator !== undefined) result.numerator = parsedNumerator; + if (parsedDenominator !== undefined) result.denominator = parsedDenominator; + return result; + }; + + const returnValue = (value: number | bigint) => + opts.verbose ? buildVerboseResult(value) : value; + if (typeof quantity === 'number' || typeof quantity === 'bigint') { - return quantity; + return returnValue(quantity); } let finalResult = NaN; + let workingString = `${quantity}`; + + // Strip currency prefix if allowed (preserving leading dash for negatives) + if (opts.allowCurrency) { + const prefixMatch = currencyPrefixRegex.exec(workingString); + if (prefixMatch && prefixMatch[2]) { + currencyPrefix = prefixMatch[2]; + // Keep the dash if present, remove currency symbol + workingString = + (prefixMatch[1] || '') + workingString.slice(prefixMatch[0].length); + } + } + + // Strip currency suffix if allowed (before percentage check) + if (opts.allowCurrency) { + const suffixMatch = currencySuffixRegex.exec(workingString); + if (suffixMatch) { + currencySuffix = suffixMatch[1]; + workingString = workingString.slice(0, -suffixMatch[0].length); + } + } - // Coerce to string in case qty is a number + // Strip percentage suffix if option is set + if (opts.percentage && percentageSuffixRegex.test(workingString)) { + percentageSuffix = true; + workingString = workingString.slice(0, -1); + } + + // Coerce to string and normalize const quantityAsString = normalizeDigits( - `${quantity}` + workingString // Convert vulgar fractions to ASCII, with a leading space // to keep the whole number and the fraction separate .replace( @@ -52,14 +119,9 @@ function numericQuantity( // Bail out if the string was only white space if (quantityAsString.length === 0) { - return NaN; + return returnValue(NaN); } - const opts: Required = { - ...defaultOptions, - ...options, - }; - let normalizedString = quantityAsString; if (opts.decimalSeparator === ',') { @@ -74,7 +136,7 @@ function numericQuantity( // The second comma and everything after is "trailing invalid" if (!opts.allowTrailingInvalid) { // Bail out if trailing invalid is not allowed - return NaN; + return returnValue(NaN); } const firstCommaIndex = quantityAsString.indexOf(','); @@ -97,16 +159,29 @@ function numericQuantity( } } - const regexResult = ( - opts.allowTrailingInvalid ? numericRegexWithTrailingInvalid : numericRegex - ).exec(normalizedString); + const regexResult = numericRegexWithTrailingInvalid.exec(normalizedString); // If the Arabic numeral regex fails, try Roman numerals if (!regexResult) { - return opts.romanNumerals ? parseRomanNumerals(quantityAsString) : NaN; + return returnValue( + opts.romanNumerals ? parseRomanNumerals(quantityAsString) : NaN); } - const [, dash, ng1temp, ng2temp] = regexResult; + // Capture trailing invalid characters: group 7 catches chars starting with + // [^.\d/], but the regex (which lacks a $ anchor) may also leave unconsumed + // input starting with ".", "/", or digits (e.g. "0.1.2" or "1/"). + const rawTrailing = ( + regexResult[7] || normalizedString.slice(regexResult[0].length) + ).trim(); + if (rawTrailing) { + trailingInvalid = rawTrailing; + if (!opts.allowTrailingInvalid) { + return returnValue(NaN); + } + } + + const [, sign, ng1temp, ng2temp] = regexResult; + if (sign === '-' || sign === '+') parsedSign = sign; const numberGroup1 = ng1temp.replaceAll(',', '').replaceAll('_', ''); const numberGroup2 = ng2temp?.replaceAll(',', '').replaceAll('_', ''); @@ -115,12 +190,13 @@ function numericQuantity( finalResult = 0; } else { if (opts.bigIntOnOverflow) { - const asBigInt = dash ? BigInt(`-${numberGroup1}`) : BigInt(numberGroup1); + const asBigInt = sign === '-' ? BigInt(`-${numberGroup1}`) : BigInt(numberGroup1); if ( asBigInt > BigInt(Number.MAX_SAFE_INTEGER) || asBigInt < BigInt(Number.MIN_SAFE_INTEGER) ) { - return asBigInt; + // Note: percentage division not applied to bigint overflow + return returnValue(asBigInt); } } @@ -130,7 +206,11 @@ function numericQuantity( // If capture group 2 is null, then we're dealing with an integer // and there is nothing left to process if (!numberGroup2) { - return dash ? finalResult * -1 : finalResult; + finalResult = sign === '-' ? finalResult * -1 : finalResult; + if (percentageSuffix && opts.percentage !== 'number') { + finalResult = finalResult / 100; + } + return returnValue(finalResult); } const roundingFactor = @@ -152,6 +232,8 @@ function numericQuantity( // If the first non-space char is "/" it's a pure fraction (e.g. "1/2") const numerator = parseInt(numberGroup1); const denominator = parseInt(numberGroup2.replace('/', '')); + parsedNumerator = numerator; + parsedDenominator = denominator; finalResult = isNaN(roundingFactor) ? numerator / denominator : Math.round((numerator * roundingFactor) / denominator) / roundingFactor; @@ -159,12 +241,24 @@ function numericQuantity( // Otherwise it's a mixed fraction (e.g. "1 2/3") const fractionArray = numberGroup2.split('/'); const [numerator, denominator] = fractionArray.map(v => parseInt(v)); + parsedWhole = finalResult; + parsedNumerator = numerator; + parsedDenominator = denominator; finalResult += isNaN(roundingFactor) ? numerator / denominator : Math.round((numerator * roundingFactor) / denominator) / roundingFactor; } - return dash ? finalResult * -1 : finalResult; + finalResult = sign === '-' ? finalResult * -1 : finalResult; + + // Apply percentage division if needed + if (percentageSuffix && opts.percentage !== 'number') { + finalResult = isNaN(roundingFactor) + ? finalResult / 100 + : Math.round((finalResult / 100) * roundingFactor) / roundingFactor; + } + + return returnValue(finalResult); } export { numericQuantity }; diff --git a/src/numericQuantityTests.ts b/src/numericQuantityTests.ts index e1ef023..8d3386e 100644 --- a/src/numericQuantityTests.ts +++ b/src/numericQuantityTests.ts @@ -53,6 +53,14 @@ export const numericQuantityTests: Record< ['012', 12], ['100', 100], ], + 'Leading + sign': [ + ['+1', 1], + ['+1.5', 1.5], + ['+1/2', 0.5], + ['+1 1/2', 1.5], + ['+.5', 0.5], + ['+1,000', 1000], + ], Separators: [ ['1,000', 1000], ['1,000,000', 1_000_000], @@ -371,4 +379,64 @@ export const numericQuantityTests: Record< ['᭑', 1], ['᭑᭒᭓', 123], ], + 'Percentage parsing': [ + // Without option - should fail + ['50%', NaN], + ['50%', NaN, { percentage: false }], + // With 'decimal' option (divide by 100) + ['50%', 0.5, { percentage: 'decimal' }], + ['100%', 1, { percentage: 'decimal' }], + ['0.5%', 0.005, { percentage: 'decimal' }], + ['1/2%', 0.005, { percentage: 'decimal' }], + ['½%', 0.005, { percentage: 'decimal' }], + ['-50%', -0.5, { percentage: 'decimal' }], + ['1 1/2%', 0.015, { percentage: 'decimal' }], + // With true (same as 'decimal') + ['50%', 0.5, { percentage: true }], + ['25%', 0.25, { percentage: true }], + // With 'number' option (keep value) + ['50%', 50, { percentage: 'number' }], + ['100%', 100, { percentage: 'number' }], + ['0.5%', 0.5, { percentage: 'number' }], + ['1/2%', 0.5, { percentage: 'number' }], + ['-50%', -50, { percentage: 'number' }], + // Roman numerals with percentage (percentage never affects Roman numeral output) + ['L%', 50, { percentage: 'decimal', romanNumerals: true }], + ['L%', 50, { percentage: 'number', romanNumerals: true }], + // Without % symbol - should work normally + ['50', 50, { percentage: 'decimal' }], + ], + 'Currency stripping': [ + // Without option - should fail + ['$100', NaN], + ['€100', NaN], + ['100€', NaN], + // With allowCurrency option + ['$100', 100, { allowCurrency: true }], + ['€100', 100, { allowCurrency: true }], + ['£100', 100, { allowCurrency: true }], + ['¥100', 100, { allowCurrency: true }], + ['₹100', 100, { allowCurrency: true }], + ['₽100', 100, { allowCurrency: true }], + ['₿100', 100, { allowCurrency: true }], + ['₩100', 100, { allowCurrency: true }], + // Suffix position + ['100€', 100, { allowCurrency: true }], + ['100£', 100, { allowCurrency: true }], + // With decimals + ['$100.50', 100.5, { allowCurrency: true }], + ['€1,000', 1000, { allowCurrency: true }], + // Negative + ['-$100', -100, { allowCurrency: true }], + // Positive with + sign + ['+$100', 100, { allowCurrency: true }], + // Multiple currency symbols + ['$$100', 100, { allowCurrency: true }], + // Currency + percentage combined + ['$50%', 0.5, { allowCurrency: true, percentage: 'decimal' }], + ['50%€', 0.5, { allowCurrency: true, percentage: 'decimal' }], + // With spaces + ['$ 100', 100, { allowCurrency: true }], + ['100 €', 100, { allowCurrency: true }], + ], }; diff --git a/src/types.ts b/src/types.ts index 0a43e20..ee46d80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,65 @@ export interface NumericQuantityOptions { // TODO: Add support for automatic decimal separator detection // decimalSeparator?: ',' | '.' | 'auto'; decimalSeparator?: ',' | '.'; + /** + * Allow and strip currency symbols (Unicode `\p{Sc}` category) from the + * start and/or end of the string. + * + * @default false + */ + allowCurrency?: boolean; + /** + * Parse percentage strings by stripping the `%` suffix. + * - `'decimal'` or `true`: `"50%"` → `0.5` (divide by 100) + * - `'number'`: `"50%"` → `50` (strip `%`, keep value) + * - `false` or omitted: `"50%"` → `NaN` (default behavior) + * + * @default false + */ + percentage?: 'decimal' | 'number' | boolean; + /** + * Return a verbose result object with additional parsing metadata. + * + * @default false + */ + verbose?: boolean; +} + +/** + * Resolves the return type of {@link numericQuantity} based on the options provided. + */ +export type NumericQuantityReturnType< + T extends NumericQuantityOptions | undefined = undefined, +> = T extends { verbose: true } + ? NumericQuantityVerboseResult + : T extends { bigIntOnOverflow: true } + ? number | bigint + : number; + +/** + * Verbose result returned when `verbose: true` is set. + */ +export interface NumericQuantityVerboseResult { + /** The parsed numeric value (NaN if invalid). */ + value: number | bigint; + /** The original input string. */ + input: string; + /** Currency symbol(s) stripped from the start, if any. */ + currencyPrefix?: string; + /** Currency symbol(s) stripped from the end, if any. */ + currencySuffix?: string; + /** True if a `%` suffix was stripped. */ + percentageSuffix?: boolean; + /** Trailing invalid (usually non-numeric) characters detected in the input, if any. Populated even when `allowTrailingInvalid` is `false`. */ + trailingInvalid?: string; + /** The leading sign character (`'-'` or `'+'`), if present. Omitted when no explicit sign was in the input. */ + sign?: '-' | '+'; + /** The whole-number part of a mixed fraction (e.g. `1` from `"1 2/3"`). Omitted for pure fractions, decimals, and integers. */ + whole?: number; + /** The numerator of a fraction (e.g. `2` from `"1 2/3"`, or `1` from `"1/2"`). Always unsigned. */ + numerator?: number; + /** The denominator of a fraction (e.g. `3` from `"1 2/3"`, or `2` from `"1/2"`). Always unsigned. */ + denominator?: number; } /** diff --git a/tsdown.config.ts b/tsdown.config.ts index d611228..2503030 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,4 +1,3 @@ -import { defaultIgnore, generateDTS } from '@jakeboone02/generate-dts'; import { writeFile } from 'fs/promises'; import type { UserConfig } from 'tsdown'; import { defineConfig } from 'tsdown'; @@ -8,7 +7,6 @@ const config: ReturnType = defineConfig(options => { entry: { 'numeric-quantity': 'src/index.ts', }, - dts: false, platform: 'neutral', sourcemap: true, ...options, @@ -25,13 +23,6 @@ const config: ReturnType = defineConfig(options => { ...commonOptions, clean: true, format: 'esm', - onSuccess: () => - generateDTS({ - ignore: filePath => - defaultIgnore(filePath) || - filePath.endsWith('Tests.ts') || - filePath.endsWith('/dev.ts'), - }), }, // ESM, Webpack 4 support. Target ES2017 syntax to compile away optional chaining and spreads { @@ -74,16 +65,22 @@ const config: ReturnType = defineConfig(options => { outDir: './dist/cjs/', onSuccess: async () => { // Write the CJS index file - await writeFile( - 'dist/cjs/index.js', - `'use strict'; + await Promise.all([ + writeFile( + 'dist/cjs/index.js', + `'use strict'; if (process.env.NODE_ENV === 'production') { module.exports = require('./numeric-quantity.cjs.production.js'); } else { module.exports = require('./numeric-quantity.cjs.development.js'); } ` - ); + ), + writeFile( + 'dist/cjs/index.d.ts', + `export * from './numeric-quantity.cjs.development.js';` + ), + ]); }, }, // UMD (ish)