From 58928c035032ce88cd3a662bc5ac3cb2c77357cb Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:26:19 -0400 Subject: [PATCH 1/2] v1.1.0: forgiving numberstring(), language fixes, Netlify playground (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: forgiving numberstring(), language fixes, Netlify playground (v1.1.0) - numberstring() accepts negatives, decimals, numeric strings, BigInt and a lang option, delegating to negative()/decimal()/toWords(); invalid input still returns false - Fix Spanish cien / un millón, French plural millions and cent mille, Italian ventunmila, Japanese 二万一千, Icelandic gender agreement - Fix decimal(1e21) truncation and parse() accepting 'nineteen eighty-four' - Add per-language spot-check table (test/languages.test.js) - Add static playground in site/ with netlify.toml and build script - Archive the Express server under archive/, drop express/supertest - Update README, CHANGELOG, CLAUDE.md, AGENTS.md * chore: npm audit fix dev deps, drop archived server lockfile * chore: restore lockfile; audit fix pulled vitest past Node 18 engine range * feat: TypeScript declarations, widen large numbers to BigInt, OG tags on playground * test: replace 1e400 literal that failed no-loss-of-precision --- .gitignore | 6 + .npmignore | 7 +- AGENTS.md | 21 +- CHANGELOG.md | 28 + CLAUDE.md | 15 +- README.md | 55 +- archive/README.md | 10 + {server => archive/server}/index.js | 0 {server => archive/server}/index.test.js | 0 {server => archive/server}/package.json | 0 eslint.config.js | 6 +- index.d.ts | 139 ++ index.js | 104 +- languages/es.js | 9 +- languages/fr.js | 7 +- languages/is.js | 47 +- languages/it.js | 2 + languages/ja.js | 9 +- netlify.toml | 12 + package-lock.json | 1226 +--------- package.json | 14 +- scripts/build-site.js | 21 + scripts/serve-site.js | 41 + server/package-lock.json | 2786 ---------------------- site/app.js | 116 + site/index.html | 88 + site/og.svg | 10 + site/style.css | 142 ++ test/index.test.js | 129 +- test/languages.test.js | 510 ++++ vitest.config.js | 3 +- 31 files changed, 1468 insertions(+), 4095 deletions(-) create mode 100644 archive/README.md rename {server => archive/server}/index.js (100%) rename {server => archive/server}/index.test.js (100%) rename {server => archive/server}/package.json (100%) create mode 100644 index.d.ts create mode 100644 netlify.toml create mode 100644 scripts/build-site.js create mode 100644 scripts/serve-site.js delete mode 100644 server/package-lock.json create mode 100644 site/app.js create mode 100644 site/index.html create mode 100644 site/og.svg create mode 100644 site/style.css create mode 100644 test/languages.test.js diff --git a/.gitignore b/.gitignore index aa6f0f0..6f3aad1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,9 @@ jspm_packages # Optional REPL history .node_repl_history + +# Playground build output (copied from index.js + languages/) +site/lib/ + +# Netlify local state +.netlify/ diff --git a/.npmignore b/.npmignore index 055e5e8..960a7e0 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,8 @@ -# Server (API) - repo only, not published to npm -server/ +# Archived code and the playground site - repo only, not published to npm +archive/ +site/ +scripts/ +netlify.toml # Tests test/ diff --git a/AGENTS.md b/AGENTS.md index 3d8d1a4..96623b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,18 +2,20 @@ ## Overview -`numberstring` is a JavaScript library that converts numbers to their English word representation. It supports numbers from 0 to `Number.MAX_SAFE_INTEGER` (~9 quadrillion). +`numberstring` is a zero-dependency JavaScript library that converts numbers to words in 22 languages, with ordinals, decimals, currency, fractions, Roman numerals, and BigInt support up to 10^36. A static playground in `site/` is deployed to Netlify. ## Key Functions ### Main Export: `numberstring(n, options)` -- Converts a number to English words +- Converts a number to words; forgiving: accepts integers, negatives, decimals, numeric strings, BigInt - Returns `string` on success, `false` on invalid input -- Options: `{ cap: 'title'|'upper'|'lower', punc: '!'|'?'|'.' }` +- Options: `{ cap: 'title'|'upper'|'lower', punc: '!'|'?'|'.', lang: 'es'|'fr'|..., point: 'point' }` ### Named Exports +- `ordinal`, `decimal`, `currency`, `roman`, `parse`, `negative`, `fraction`, `year`, `telephone`, `percent`, `toWords` - `comma(n)` - Format number with comma separators - `group(n)` - Get magnitude group (0=ones, 1=thousands, 2=millions, etc.) +- One named export per language (`spanish`, `french`, ...) ## Usage Examples @@ -21,7 +23,9 @@ import numberstring, { comma, group } from 'numberstring'; numberstring(42); // 'forty-two' -numberstring(1000000); // 'one million' +numberstring(-3.14); // 'negative three point one four' +numberstring('1000000'); // 'one million' +numberstring(42, { lang: 'es' }); // 'cuarenta y dos' numberstring(100, { cap: 'title' }); // 'One Hundred' numberstring(50, { punc: '!' }); // 'fifty!' @@ -31,8 +35,8 @@ group(1000000); // 2 (millions) ## Important Notes -- Only accepts non-negative integers within safe integer range -- Returns `false` for: NaN, Infinity, negative numbers, strings, objects +- Returns `false` for: NaN, Infinity, non-numeric strings, objects, values beyond 10^36 +- Language modules handle non-negative integers only; negatives/decimals with a non-English `lang` return `false` - Hyphenates compound numbers (e.g., "forty-two", "ninety-nine") - This is an ESM-only package (use `import`, not `require`) @@ -46,7 +50,10 @@ npm run test:coverage # Run with coverage ## Code Architecture -- `index.js` - Single file containing all logic +- `index.js` - English core and all public helpers +- `languages/*.js` - one module per language; `test/languages.test.js` is the per-language spot-check table +- `site/` - playground; `scripts/build-site.js` stages the library into `site/lib/` +- `archive/` - unmaintained code (old Express server), excluded from tests and lint - Pure functions, no side effects - Frozen arrays for immutable word lists - Full JSDoc type documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index 808bf9c..ce73f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-10-02 + +### Added + +- **Forgiving default export** - `numberstring()` now accepts negatives, decimals, numeric strings, and a `lang` option, delegating to `negative()`, `decimal()`, and `toWords()`. Invalid input still returns `false`. +- **Playground** - Static site in `site/` deployed to Netlify. Type a number, see it in 22 languages plus ordinal, Roman, year, currency, and more. +- **Per-language spot-check tests** - `test/languages.test.js` locks in tricky numbers (21, 71, 80, 91, 100, 101, 1000, 1001, 2000, 21000, 1M, 2M, 21M) for all 22 languages. +- **TypeScript declarations** - `index.d.ts` covering the default export, every helper, options, and the language functions. +- Numbers above `Number.MAX_SAFE_INTEGER` (e.g. `1e21`) are widened to BigInt and converted instead of returning `false`. +- Open Graph and Twitter card tags on the playground. +- `npm run site` and `npm run site:build` scripts. + +### Fixed + +- Spanish: `100` is now `cien` (was `ciento`); `uno` apocopates before scale words (`un millón`, `veintiún mil`, `ciento un millones`). +- French: `million`/`milliard` pluralize (`deux millions`); `cents` and `quatre-vingts` drop the `s` before `mille` (`deux cent mille`). +- Italian: `-uno` elides before `mila` (`ventunmila`). +- Japanese: `一千` is kept after a higher scale word (`二万一千`, was `二万千`). +- Icelandic: gender agreement for 1–4 before `hundrað`/`þúsund` (neuter: `tvö þúsund`), `milljón` (feminine: `ein milljón`, `tvær milljónir`), and singular after numbers ending in 1 (`tuttugu og ein milljón`). +- `decimal(1e21)` returned `"one"`; exponent-form integers are now expanded, exponent-form fractions return `false`. +- `decimal()` kept only 15 digits of precision for long integer parts in strings. +- `parse('nineteen eighty-four')` returned `103`; adjacent simple words other than tens + ones now return `false`. + +### Changed + +- The Express REST server moved to `archive/server/` and is no longer tested, linted, or maintained. The playground supersedes it. +- Removed `express` and `supertest` from devDependencies. + ## [1.0.1] - 2026-02-08 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 18ec6d2..6326f68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ npm install # Install dev dependencies npm test # Run Vitest tests npm run lint # Run ESLint npm run test:coverage # Run tests with coverage report +npm run site # Build + serve the playground at http://localhost:8080 ``` ## Code Style @@ -18,7 +19,7 @@ npm run test:coverage # Run tests with coverage report - ES2022+ syntax (const/let, arrow functions, template literals) - ESM modules only (`import`/`export`) - Full JSDoc documentation -- 100% test coverage target +- Keep coverage at or above 90% (current level); 100% on functions ## Architecture @@ -33,7 +34,7 @@ Key constants: ## When Making Changes 1. **ALWAYS run lint and tests before committing**: `npm run lint && npm test` -2. Maintain 100% coverage: `npm run test:coverage` +2. Keep coverage from dropping: `npm run test:coverage` 3. Update CHANGELOG.md for any user-facing changes 4. Preserve the fun flair (ASCII art header, tagline) @@ -44,6 +45,14 @@ Key constants: - Fix valid issues before pushing new commits - Use `gh pr view --comments` to fetch PR comments +## Layout + +- `index.js` - core English conversion plus all public helpers; `numberstring()` is forgiving and delegates to `negative()`, `decimal()`, `toWords()` +- `languages/` - one module per language, cardinals only, non-negative integers only +- `test/languages.test.js` - per-language spot-check table; update expectations when fixing a language +- `site/` - static playground deployed to Netlify (`netlify.toml`); `scripts/build-site.js` copies the library into `site/lib/` +- `archive/server/` - old Express API, unmaintained, excluded from tests and lint; do not extend it + ## Supported Languages - English (default) @@ -55,6 +64,7 @@ Key constants: - Hindi (`hi`, `hindi`, `हिन्दी`) - Russian (`ru`, `russian`, `русский`) - Portuguese (`pt`, `portuguese`, `português`) +- Plus Japanese, Korean, Arabic, Italian, Dutch, Turkish, Polish, Swedish, Indonesian, Thai, Norwegian, Finnish, Icelandic (22 total) ## Features @@ -66,6 +76,7 @@ Key constants: - Roman numerals (42 → "XLII") - Negative numbers - BigInt support up to 10^36 +- Forgiving input: `numberstring(-3.14)`, `numberstring('42')`, `numberstring(42, { lang: 'de' })` all work; invalid input returns `false` --- diff --git a/README.md b/README.md index 63b6dac..3db9a3c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `1000000` to `"one million"`. Supports **22 languages**, ordinals, currency, Roman numerals, and more! +**Try it:** [numberstring.netlify.app](https://numberstring.netlify.app) — type a number, see it in 22 languages. Runs entirely in your browser. + ## Why numberstring? - **Zero dependencies** - Lightweight and fast @@ -20,8 +22,9 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100 - **Huge range** - Supports 0 to decillions (10^36) with BigInt - **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers - **Roman numerals** - Convert to and from Roman numerals -- **Well tested** - 265 tests with 90%+ coverage -- **Modern ES modules** - Tree-shakeable, TypeScript-friendly +- **Forgiving input** - Integers, negatives, decimals, numeric strings, BigInt. It just works +- **Well tested** - 660+ tests with 90%+ coverage, including per-language spot checks +- **Modern ES modules** - Tree-shakeable, with bundled TypeScript declarations ## Installation @@ -37,6 +40,9 @@ import numberstring from 'numberstring'; numberstring(42); // 'forty-two' numberstring(1000000); // 'one million' numberstring(10n ** 18n); // 'one quintillion' (BigInt!) +numberstring(-3.14); // 'negative three point one four' +numberstring('1000'); // 'one thousand' +numberstring(42, { lang: 'es' }); // 'cuarenta y dos' numberstring(123, { cap: 'title' }); // 'One Hundred Twenty-Three' ``` @@ -46,14 +52,21 @@ numberstring(123, { cap: 'title' }); // 'One Hundred Twenty-Three' #### `numberstring(n, [options])` -Convert a number to English words. +Convert a number to words. Forgiving by design: accepts integers, negatives, decimals, numeric strings, and BigInt, and honors `lang`. Returns `false` for anything it cannot say. ```javascript numberstring(42); // 'forty-two' +numberstring(-7); // 'negative seven' +numberstring(2.5); // 'two point five' +numberstring('1000000'); // 'one million' +numberstring(42, { lang: 'fr' }); // 'quarante-deux' numberstring(100, { cap: 'title' }); // 'One Hundred' numberstring(100, { punc: '!' }); // 'one hundred!' +numberstring('abc'); // false ``` +Negatives and decimals are English-only; with another `lang` they return `false` rather than falling back to English. + #### `ordinal(n, [options])` Convert to ordinal words (first, second, etc.). @@ -262,7 +275,7 @@ Languages are modular! To add a new language: |--------|------|-------------| | `cap` | `string` | Capitalization: `'title'`, `'upper'`, or `'lower'` | | `punc` | `string` | Punctuation: `'!'`, `'?'`, or `'.'` | -| `lang` | `string` | Language code for `toWords()` | +| `lang` | `string` | Language code for `numberstring()` and `toWords()` | | `point` | `string` | Word for decimal point (default: `'point'`) | | `lower` | `boolean` | Lowercase Roman numerals | @@ -283,40 +296,15 @@ Languages are modular! To add a new language: | **Nonillions** | 10^30 | `five nonillion` *(BigInt)* | | **Decillions** | 10^33 | `five decillion` *(BigInt)* | -## REST API Server +## Playground -numberstring includes a ready-to-use REST API server! +The playground at [numberstring.netlify.app](https://numberstring.netlify.app) is a single static page in `site/` that imports the library directly. No framework, no backend. ```bash -cd server -npm install -npm start -# Server running at http://localhost:3456 +npm run site # stage the library into site/lib and serve at http://localhost:8080 ``` -### Endpoints - -| Endpoint | Description | Example | -|----------|-------------|---------| -| `GET /convert/:n` | Number to words | `/convert/42` → `"forty-two"` | -| `GET /convert/:n?lang=es` | Multi-language | `/convert/42?lang=es` → `"cuarenta y dos"` | -| `GET /ordinal/:n` | Ordinal words | `/ordinal/3` → `"third"` | -| `GET /decimal/:n` | Decimal words | `/decimal/3.14` → `"three point one four"` | -| `GET /currency/:amt` | Currency words | `/currency/$99.99` → `"ninety-nine dollars..."` | -| `GET /roman/:n` | Roman numerals | `/roman/2024` → `"MMXXIV"` | -| `GET /parse/:words` | Words to number | `/parse/forty-two` → `42` | -| `GET /comma/:n` | Format with commas | `/comma/1000000` → `"1,000,000"` | -| `GET /languages` | List languages | Returns supported language codes | - -### Example - -```bash -curl http://localhost:3456/convert/1000000000000000 -# {"input":"1000000000000000","output":"one quadrillion","lang":"en"} - -curl "http://localhost:3456/convert/42?lang=fr" -# {"input":"42","output":"quarante-deux","lang":"fr"} -``` +The former Express REST server lives in `archive/server/`, unmaintained. ## Development @@ -325,6 +313,7 @@ npm install # Install dependencies npm test # Run tests npm run lint # Run linter npm run test:coverage # Test with coverage +npm run site # Run the playground locally ``` ## Contributing diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..b8138ae --- /dev/null +++ b/archive/README.md @@ -0,0 +1,10 @@ +# Archive + +Code kept for reference but no longer maintained, tested, linted, or published. + +## server/ + +An Express REST API wrapping numberstring. Never deployed. Superseded by the +static playground in `site/`, which runs the library directly in the browser +with no backend. If a hosted API is ever wanted, these handlers port directly +to Netlify Functions. Slated for deletion. diff --git a/server/index.js b/archive/server/index.js similarity index 100% rename from server/index.js rename to archive/server/index.js diff --git a/server/index.test.js b/archive/server/index.test.js similarity index 100% rename from server/index.test.js rename to archive/server/index.test.js diff --git a/server/package.json b/archive/server/package.json similarity index 100% rename from server/package.json rename to archive/server/package.json diff --git a/eslint.config.js b/eslint.config.js index 759fb14..65772b4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,10 @@ export default [ } }, { - ignores: ['node_modules/', 'coverage/'] + files: ['site/**/*.js'], + languageOptions: { globals: { document: 'readonly', window: 'readonly', location: 'readonly', history: 'readonly' } } + }, + { + ignores: ['node_modules/', 'coverage/', 'archive/', 'site/lib/'] } ]; diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..3554c1f --- /dev/null +++ b/index.d.ts @@ -0,0 +1,139 @@ +/** + * Type declarations for numberstring + */ + +/** Capitalization styles */ +export type CapStyle = 'title' | 'upper' | 'lower'; + +/** Trailing punctuation */ +export type Punc = '!' | '?' | '.'; + +/** Language codes and aliases accepted by `lang` */ +export type Lang = + | 'en' | 'english' + | 'es' | 'spanish' | 'español' + | 'fr' | 'french' | 'français' + | 'de' | 'german' | 'deutsch' + | 'da' | 'danish' | 'dansk' + | 'zh' | 'chinese' | 'mandarin' | '中文' + | 'hi' | 'hindi' | 'हिन्दी' + | 'ru' | 'russian' | 'русский' + | 'pt' | 'portuguese' | 'português' + | 'ja' | 'japanese' | '日本語' + | 'ko' | 'korean' | '한국어' + | 'ar' | 'arabic' | 'العربية' + | 'it' | 'italian' | 'italiano' + | 'nl' | 'dutch' | 'nederlands' + | 'tr' | 'turkish' | 'türkçe' + | 'pl' | 'polish' | 'polski' + | 'sv' | 'swedish' | 'svenska' + | 'id' | 'indonesian' | 'bahasa' + | 'th' | 'thai' | 'ไทย' + | 'no' | 'norwegian' | 'norsk' + | 'fi' | 'finnish' | 'suomi' + | 'is' | 'icelandic' | 'íslenska' + | (string & {}); + +export interface Options { + /** Capitalization: 'title', 'upper', or 'lower' */ + cap?: CapStyle; + /** Trailing punctuation: '!', '?', or '.' */ + punc?: Punc; + /** Language code (default 'en'). Non-English languages cover non-negative integers only. */ + lang?: Lang; + /** Word for the decimal point (default 'point') */ + point?: string; +} + +export interface CurrencyOptions extends Pick { + /** Currency symbol or ISO code when the amount has none: '$', 'USD', '€', 'EUR', '£', 'GBP', '¥', 'JPY', '₹', 'INR', '元', 'CNY' */ + currency?: string; +} + +export interface RomanOptions { + /** Return lowercase numerals */ + lower?: boolean; +} + +/** A conversion result, or `false` when the input cannot be converted */ +export type Result = string | false; + +/** Any value numberstring will try to convert */ +export type Numeric = number | bigint | string; + +/** + * Convert a number to words. Accepts integers, negatives, decimals, + * numeric strings, and BigInt up to 10^36 - 1. Honors `lang`. + * + * @example + * numberstring(42) // 'forty-two' + * numberstring(-3.14) // 'negative three point one four' + * numberstring('1000') // 'one thousand' + * numberstring(42, { lang: 'es' }) // 'cuarenta y dos' + */ +declare function numberstring(n: Numeric, opt?: Options): Result; +export default numberstring; + +/** Convert to words in any supported language (non-negative integers) */ +export function toWords(n: number | bigint, opt?: Pick): Result; + +/** Ordinal words: 1 → 'first', 21 → 'twenty-first' */ +export function ordinal(n: number | bigint, opt?: Pick): Result; + +/** Decimal words: 3.14 → 'three point one four' */ +export function decimal(n: number | string, opt?: Pick): Result; + +/** Currency words: '$123.45' → 'one hundred twenty-three dollars and forty-five cents' */ +export function currency(amount: number | string, opt?: CurrencyOptions): Result; + +/** Roman numerals for 1–3999: 42 → 'XLII' */ +export function roman(n: number, opt?: RomanOptions): Result; + +/** Parse English words back to a number: 'forty-two' → 42. Returns BigInt above the safe integer range. */ +export function parse(str: string): number | bigint | false; + +/** Signed words: -42 → 'negative forty-two' */ +export function negative(n: number | bigint, opt?: Pick): Result; + +/** Fraction words: (1, 2) → 'one half', (3, 4) → 'three quarters' */ +export function fraction(numerator: number, denominator: number, opt?: Pick): Result; + +/** Year as spoken: 1984 → 'nineteen eighty-four' */ +export function year(y: number, opt?: Pick): Result; + +/** Digits read individually: '555-1234' → 'five five five one two three four' */ +export function telephone(phone: number | string, opt?: Pick): Result; + +/** Percent words: 50 → 'fifty percent' */ +export function percent(pct: number | string, opt?: Pick): Result; + +/** Thousands separators: 1234567 → '1,234,567' */ +export function comma(n: number | bigint): string | false; + +/** Magnitude group: 0 = ones, 1 = thousands, 2 = millions, ... */ +export function group(n: number | bigint): number; + +/** A single-language converter for non-negative integers */ +export type LanguageConverter = (n: number | bigint, opt?: Pick) => Result; + +export const spanish: LanguageConverter; +export const french: LanguageConverter; +export const german: LanguageConverter; +export const danish: LanguageConverter; +export const chinese: LanguageConverter; +export const hindi: LanguageConverter; +export const russian: LanguageConverter; +export const portuguese: LanguageConverter; +export const japanese: LanguageConverter; +export const korean: LanguageConverter; +export const arabic: LanguageConverter; +export const italian: LanguageConverter; +export const dutch: LanguageConverter; +export const turkish: LanguageConverter; +export const polish: LanguageConverter; +export const swedish: LanguageConverter; +export const indonesian: LanguageConverter; +export const thai: LanguageConverter; +export const norwegian: LanguageConverter; +export const finnish: LanguageConverter; +export const icelandic: LanguageConverter; diff --git a/index.js b/index.js index e07ac47..e15680b 100644 --- a/index.js +++ b/index.js @@ -147,14 +147,13 @@ const comma = (n) => { // ============================================================================ /** - * Convert a number or BigInt to its English word representation + * Convert a non-negative integer (number or BigInt) to English words. + * Internal: the public `string()` normalizes input and delegates here. * @param {number|bigint} n - The number to convert (0 to 10^36-1) * @param {Object} [opt] - Options object - * @param {string} [opt.cap] - Capitalization: 'title', 'upper', or 'lower' - * @param {string} [opt.punc] - Punctuation: '!', '?', or '.' * @returns {string|false} The word representation or false if invalid */ -const string = (n, opt) => { +const cardinal = (n, opt) => { let num; if (typeof n === 'bigint') { @@ -192,6 +191,70 @@ const string = (n, opt) => { return s; }; +/** + * Convert a number to its word representation. + * + * Forgiving by design: accepts integers, negatives, decimals, numeric strings, + * and BigInt, and honors `opt.lang` for any of the 22 supported languages. + * Invalid input returns `false`. + * + * @param {number|bigint|string} n - The number to convert + * @param {Object} [opt] - Options object + * @param {string} [opt.cap] - Capitalization: 'title', 'upper', or 'lower' + * @param {string} [opt.punc] - Punctuation: '!', '?', or '.' + * @param {string} [opt.lang] - Language code (default 'en') + * @param {string} [opt.point] - Word for the decimal point (default 'point') + * @returns {string|false} The word representation or false if invalid + * + * @example + * numberstring(42) // 'forty-two' + * numberstring(-5) // 'negative five' + * numberstring(3.14) // 'three point one four' + * numberstring('1000') // 'one thousand' + * numberstring(42, { lang: 'es' }) // 'cuarenta y dos' + */ +const string = (n, opt) => { + let value = n; + // Delegates apply `cap` themselves; `punc` is applied once in finish() + const inner = opt ? { ...opt, punc: undefined } : opt; + const lang = opt?.lang?.toLowerCase(); + const foreign = Boolean(lang && LANGUAGES[lang] && LANGUAGES[lang] !== 'english'); + + if (typeof value === 'string') { + const str = value.trim(); + if (!/^-?\d+(\.\d+)?$/.test(str)) return false; + // Other languages only cover non-negative integers; don't fall back to English + if (str.includes('.')) return foreign ? false : finish(decimal(str, inner), opt); + const digits = str.replace('-', ''); + value = digits.length <= 15 ? Number(str) : BigInt(str); + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return false; + if (!Number.isInteger(value)) return foreign ? false : finish(decimal(value, inner), opt); + // 1e21 is an integer too; widen to BigInt rather than rejecting it + if (Math.abs(value) > Number.MAX_SAFE_INTEGER) value = BigInt(value); + } + + if (typeof value === 'number') { + if (value < 0) return foreign ? false : finish(negative(value, inner), opt); + } else if (typeof value === 'bigint') { + if (value < 0n) return foreign ? false : finish(negative(value, inner), opt); + } else { + return false; + } + + if (foreign) return finish(toWords(value, inner), opt); + return cardinal(value, opt); +}; + +/** Apply `punc` to a delegated result (helpers already apply `cap`). */ +const finish = (result, opt) => { + if (result === false) return false; + if (opt?.punc !== undefined) return punc(result, opt.punc); + return result; +}; + // ============================================================================ // ORDINAL FUNCTION // ============================================================================ @@ -226,10 +289,10 @@ const ordinal = (n, opt) => { s = `${TENS[tensDigit]}-${ORDINAL_ONES[onesDigit]}`; } } else { - const cardinal = string(num); - if (!cardinal) return false; + const base = cardinal(num); + if (!base) return false; - const parts = cardinal.split(' '); + const parts = base.split(' '); const lastPart = parts[parts.length - 1]; const hyphenParts = lastPart.split('-'); const lastWord = hyphenParts[hyphenParts.length - 1]; @@ -272,8 +335,12 @@ const decimal = (n, opt) => { let numStr; if (typeof n === 'number') { - if (isNaN(n)) return false; + if (!Number.isFinite(n)) return false; numStr = n.toString(); + if (numStr.includes('e')) { + if (!Number.isInteger(n)) return false; + numStr = BigInt(n).toString(); + } } else if (typeof n === 'string') { numStr = n.trim(); if (!/^-?\d+\.?\d*$/.test(numStr) && !/^-?\d*\.?\d+$/.test(numStr)) { @@ -289,8 +356,9 @@ const decimal = (n, opt) => { const pointWord = opt?.point || 'point'; const [intPart, decPart] = numStr.split('.'); - const intNum = parseInt(intPart || '0', 10); - const intWords = string(intNum); + const intDigits = intPart || '0'; + const intNum = intDigits.length <= 15 ? parseInt(intDigits, 10) : BigInt(intDigits); + const intWords = cardinal(intNum); if (intWords === false) return false; let result = isNegative ? 'negative ' : ''; @@ -419,22 +487,30 @@ const parse = (str) => { let result = useBigInt ? 0n : 0; let current = useBigInt ? 0n : 0; + let lastSimple = null; for (const word of words) { if (Object.hasOwn(WORD_VALUES, word)) { const val = WORD_VALUES[word]; + // Only "tens + ones" (twenty two) may follow a simple word directly; + // "nineteen eighty" is a year, not a cardinal + const tensThenOnes = lastSimple >= 20 && lastSimple % 10 === 0 && val >= 1 && val <= 9; + if (lastSimple !== null && !tensThenOnes) return false; + lastSimple = val; if (useBigInt) { current += BigInt(val); } else { current += val; } } else if (word === 'hundred') { + lastSimple = null; if (useBigInt) { current *= 100n; } else { current *= 100; } } else if (Object.hasOwn(SCALE_VALUES, word)) { + lastSimple = null; const scale = SCALE_VALUES[word]; if (useBigInt) { const bigScale = typeof scale === 'bigint' ? scale : BigInt(scale); @@ -463,8 +539,8 @@ const parse = (str) => { const negative = (n, opt) => { if (typeof n === 'bigint') { - if (n >= 0n) return string(n, opt); - const result = string(-n, opt); + if (n >= 0n) return cardinal(n, opt); + const result = cardinal(-n, opt); if (result === false) return false; let s = `negative ${result}`; if (opt?.cap) s = cap(s, opt.cap); @@ -473,9 +549,9 @@ const negative = (n, opt) => { if (typeof n !== 'number' || isNaN(n)) return false; - if (n >= 0) return string(n, opt); + if (n >= 0) return cardinal(n, opt); - const result = string(Math.abs(n), opt); + const result = cardinal(Math.abs(n), opt); if (result === false) return false; let s = `negative ${result}`; if (opt?.cap) s = cap(s, opt.cap); diff --git a/languages/es.js b/languages/es.js index 5b27e91..3b99bb9 100644 --- a/languages/es.js +++ b/languages/es.js @@ -21,7 +21,7 @@ const tenment = (n, g) => hundment(n, g) % 100; const hundredEs = (n) => { if (n < 100 || n >= 1000) return ''; const h = Math.floor(n / 100); - if (h === 1) return 'ciento '; + if (h === 1) return n === 100 ? 'cien ' : 'ciento '; if (h === 5) return 'quinientos '; if (h === 7) return 'setecientos '; if (h === 9) return 'novecientos '; @@ -88,8 +88,11 @@ const spanish = (n, opt) => { if (h === 1 && i === 1) { s += 'mil '; } else { - s += hundredEs(h); - s += tenEs(tenment(num, i)); + // "uno" apocopates to "un" before a scale word: un millón, veintiún mil + const groupWords = (hundredEs(h) + tenEs(tenment(num, i))) + .replace(/veintiuno $/, 'veintiún ') + .replace(/uno $/, 'un '); + s += groupWords; const illionWord = h === 1 ? ES_ILLIONS[i] : ES_ILLIONS_PLURAL[i]; s += `${illionWord} `; } diff --git a/languages/fr.js b/languages/fr.js index 9fd7893..806f40f 100644 --- a/languages/fr.js +++ b/languages/fr.js @@ -84,10 +84,15 @@ const french = (n) => { s += tenFr(tenment(num, i)); } else if (i === 1 && h === 1) { s += 'mille '; + } else if (i === 1) { + // "cents" and "quatre-vingts" lose their plural s before "mille" + const groupWords = (hundredFr(h) + tenFr(tenment(num, i))).replace(/(cent|vingt)s $/, '$1 '); + s += `${groupWords}mille `; } else { s += hundredFr(h); s += tenFr(tenment(num, i)); - s += `${FR_ILLIONS[i]} `; + // million, milliard, etc. are nouns and take a plural s + s += h > 1 ? `${FR_ILLIONS[i]}s ` : `${FR_ILLIONS[i]} `; } } } diff --git a/languages/is.js b/languages/is.js index 4ce9a39..6409d88 100644 --- a/languages/is.js +++ b/languages/is.js @@ -18,23 +18,38 @@ const segment = (n, g) => n % power(g + 1); const hundment = (n, g) => Number(segment(n, g) / power(g)); const tenment = (n, g) => hundment(n, g) % 100; -const tenIs = (n) => { +// 1-4 decline for gender: masculine (default), feminine (milljón), neuter (hundrað, þúsund) +const IS_ONES_F = Object.freeze(['', 'ein', 'tvær', 'þrjár', 'fjórar']); +const IS_ONES_N = Object.freeze(['', 'eitt', 'tvö', 'þrjú', 'fjögur']); + +const onesIs = (d, gender) => { + if (d >= 1 && d <= 4) { + if (gender === 'f') return IS_ONES_F[d]; + if (gender === 'n') return IS_ONES_N[d]; + } + return IS_ONES[d]; +}; + +const tenIs = (n, gender) => { if (n === 0) return ''; - if (n < 10) return IS_ONES[n]; + if (n < 10) return onesIs(n, gender); if (n < 20) return IS_TEENS[n - 10]; const onesDigit = n % 10; const tensDigit = Math.floor(n / 10); if (onesDigit === 0) return IS_TENS[tensDigit]; - return `${IS_TENS[tensDigit]} og ${IS_ONES[onesDigit]}`; + return `${IS_TENS[tensDigit]} og ${onesIs(onesDigit, gender)}`; }; const hundredIs = (n) => { if (n < 100 || n >= 1000) return ''; const h = Math.floor(n / 100); if (h === 1) return 'eitt hundrað'; - return `${IS_ONES[h]} hundruð`; + return `${onesIs(h, 'n')} hundruð`; }; +/** Grammatical gender of each scale word: -jón feminine, -jarður masculine, þúsund neuter */ +const illionGender = (i) => (i === 1 ? 'n' : i % 2 === 0 ? 'f' : 'm'); + /** * Convert a number to Icelandic words * @param {number|bigint} n - The number to convert @@ -69,28 +84,14 @@ const icelandic = (n) => { if (h >= 100) s += hundredIs(h) + ' '; const t = tenment(num, i); if (t > 0) s += tenIs(t); - } else if (i === 1) { - if (h === 1) { - s += 'eitt þúsund '; - } else { - if (h >= 100) s += hundredIs(h) + ' '; - const t = tenment(num, i); - if (t > 0) s += tenIs(t) + ' '; - s += 'þúsund '; - } } else { + const gender = illionGender(i); if (h >= 100) s += hundredIs(h) + ' '; const t = tenment(num, i); - if (t > 0) { - if (t === 1) { - s += 'einn '; - } else { - s += tenIs(t) + ' '; - } - } else if (h < 100 && h >= 1 && h === 1) { - s += 'einn '; - } - const illionWord = h === 1 ? IS_ILLIONS[i] : IS_ILLIONS_PLURAL[i]; + if (t > 0) s += tenIs(t, gender) + ' '; + // Numbers ending in 1 (but not 11) take the singular: tuttugu og ein milljón + const singular = h % 10 === 1 && h % 100 !== 11; + const illionWord = singular ? IS_ILLIONS[i] : IS_ILLIONS_PLURAL[i]; s += `${illionWord} `; } } diff --git a/languages/it.js b/languages/it.js index 1aeda77..6f2a109 100644 --- a/languages/it.js +++ b/languages/it.js @@ -105,6 +105,8 @@ const italian = (n) => { if (h >= 100) s += hundredIt(h); const t = tenment(num, i); if (t > 0) s += tenIt(t); + // "-uno" elides before mila: ventunmila, trentunmila + s = s.replace(/(ant|ent)uno$/, '$1un'); s += 'mila'; } } else { diff --git a/languages/ja.js b/languages/ja.js index 4b7ce4b..73f9838 100644 --- a/languages/ja.js +++ b/languages/ja.js @@ -16,7 +16,7 @@ const MAX_VALUE = 10n ** 36n - 1n; * @param {number} grp - The group value (0-9999) * @returns {string} The Japanese representation */ -const groupToJa = (grp) => { +const groupToJa = (grp, afterScale = false) => { if (grp === 0) return ''; const thousands = Math.floor(grp / 1000); @@ -26,9 +26,10 @@ const groupToJa = (grp) => { let result = ''; - // Thousands: 1 before 千 is omitted + // Thousands: 1 before 千 is omitted at the start (千) but kept after a + // higher scale word (二万一千) if (thousands > 0) { - if (thousands === 1) { + if (thousands === 1 && !afterScale) { result += '千'; } else { result += JA_DIGITS[thousands] + '千'; @@ -105,7 +106,7 @@ const japanese = (n) => { if (grp === 0) continue; - const grpStr = groupToJa(grp); + const grpStr = groupToJa(grp, i > 0); // 1 before 万 and above IS included (handled naturally by groupToJa // since grp=1 produces '一' for the ones digit in the group) diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..e64c833 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,12 @@ +[build] + command = "node scripts/build-site.js" + publish = "site" + +[build.environment] + NODE_VERSION = "22" + +[[headers]] + for = "/*" + [headers.values] + X-Content-Type-Options = "nosniff" + Referrer-Policy = "strict-origin-when-cross-origin" diff --git a/package-lock.json b/package-lock.json index 10d0a6c..5b4b0c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,16 @@ { "name": "numberstring", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "numberstring", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.17.0", - "express": "^4.21.2", - "supertest": "^7.0.0", "vitest": "^4.0.18" }, "engines": { @@ -745,29 +743,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1299,20 +1274,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1376,20 +1337,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1412,13 +1359,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1426,48 +1366,6 @@ "dev": true, "license": "MIT" }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1479,47 +1377,6 @@ "concat-map": "0.0.1" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1577,29 +1434,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1607,53 +1441,6 @@ "dev": true, "license": "MIT" }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1694,100 +1481,6 @@ "dev": true, "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1795,35 +1488,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1866,13 +1530,6 @@ "@esbuild/win32-x64": "0.27.2" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2050,16 +1707,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2070,70 +1717,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2155,13 +1738,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2193,42 +1769,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2267,216 +1807,57 @@ "dev": true, "license": "ISC" }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 6" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" + "node": ">=10.13.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2484,40 +1865,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2555,23 +1902,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2767,82 +2097,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2889,29 +2143,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2923,29 +2154,6 @@ ], "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3009,16 +2217,6 @@ "node": ">=6" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3039,13 +2237,6 @@ "node": ">=8" } }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3112,20 +2303,6 @@ "node": ">= 0.8.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3136,48 +2313,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3233,34 +2368,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3274,71 +2381,6 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3362,82 +2404,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3462,16 +2428,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -3492,65 +2448,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/superagent": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.1", - "cookiejar": "^2.1.4", - "debug": "^4.3.7", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.5", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.14.1" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/superagent/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/supertest": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-signature": "^1.2.2", - "methods": "^1.1.2", - "superagent": "^10.3.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supertest/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3608,16 +2505,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3631,30 +2518,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3665,26 +2528,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3881,13 +2724,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8654a0a..b6a61ef 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "numberstring", - "version": "1.0.1", + "version": "1.1.0", "description": "Number One Way to Makes Words from Numbers", "type": "module", "main": "index.js", "exports": { ".": { + "types": "./index.d.ts", "import": "./index.js" } }, @@ -15,7 +16,9 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint . --format stylish", - "lint:report": "eslint . --format html -o coverage/lint-report.html" + "lint:report": "eslint . --format html -o coverage/lint-report.html", + "site:build": "node scripts/build-site.js", + "site": "node scripts/build-site.js && node scripts/serve-site.js" }, "repository": { "type": "git", @@ -39,15 +42,14 @@ "bugs": { "url": "https://github.com/brianfunk/numberstring/issues" }, - "homepage": "https://github.com/brianfunk/numberstring#readme", + "homepage": "https://numberstring.netlify.app", "engines": { "node": ">=18.0.0" }, "devDependencies": { "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.17.0", - "express": "^4.21.2", - "supertest": "^7.0.0", "vitest": "^4.0.18" - } + }, + "types": "index.d.ts" } diff --git a/scripts/build-site.js b/scripts/build-site.js new file mode 100644 index 0000000..51ab049 --- /dev/null +++ b/scripts/build-site.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Stage the library into site/lib/ so the playground can import it with a + * relative URL. Netlify runs this as the build command; no bundler needed. + */ + +/* global process */ + +import { cpSync, mkdirSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const lib = join(root, 'site', 'lib'); + +rmSync(lib, { recursive: true, force: true }); +mkdirSync(lib, { recursive: true }); +cpSync(join(root, 'index.js'), join(lib, 'index.js')); +cpSync(join(root, 'languages'), join(lib, 'languages'), { recursive: true }); + +process.stdout.write('site/lib/ staged from index.js + languages/\n'); diff --git a/scripts/serve-site.js b/scripts/serve-site.js new file mode 100644 index 0000000..56eeb1f --- /dev/null +++ b/scripts/serve-site.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Minimal static server for local playground development. + * Run: npm run site (then open http://localhost:8080) + */ + +/* global process, URL */ + +import { createServer } from 'node:http'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { dirname, extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const site = join(dirname(fileURLToPath(import.meta.url)), '..', 'site'); +const PORT = process.env.PORT || 8080; + +const TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon' +}; + +createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + let file = join(site, normalize(decodeURIComponent(url.pathname))); + if (!file.startsWith(site)) { + res.writeHead(403).end(); + return; + } + if (existsSync(file) && statSync(file).isDirectory()) file = join(file, 'index.html'); + if (!existsSync(file)) { + res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found'); + return; + } + res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream' }); + createReadStream(file).pipe(res); +}).listen(PORT, () => { + process.stdout.write(`playground at http://localhost:${PORT}\n`); +}); diff --git a/server/package-lock.json b/server/package-lock.json deleted file mode 100644 index 473afe6..0000000 --- a/server/package-lock.json +++ /dev/null @@ -1,2786 +0,0 @@ -{ - "name": "numberstring-api", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "numberstring-api", - "version": "1.0.0", - "dependencies": { - "express": "^4.21.2" - }, - "devDependencies": { - "@vitest/coverage-v8": "^4.0.0", - "supertest": "^7.0.0", - "vitest": "^4.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", - "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/superagent": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.1", - "cookiejar": "^2.1.4", - "debug": "^4.3.7", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.5", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.14.1" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/superagent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/superagent/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/superagent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/supertest": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-signature": "^1.2.2", - "methods": "^1.1.2", - "superagent": "^10.3.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supertest/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - } - } -} diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..0c9dd88 --- /dev/null +++ b/site/app.js @@ -0,0 +1,116 @@ +import numberstring, { + comma, ordinal, roman, year, currency, telephone, fraction +} from './lib/index.js'; + +const LANGS = [ + ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], + ['pt', 'Português'], ['it', 'Italiano'], ['nl', 'Nederlands'], ['da', 'Dansk'], + ['sv', 'Svenska'], ['no', 'Norsk'], ['fi', 'Suomi'], ['is', 'Íslenska'], + ['pl', 'Polski'], ['ru', 'Русский'], ['tr', 'Türkçe'], ['ar', 'العربية'], + ['hi', 'हिन्दी'], ['zh', '中文'], ['ja', '日本語'], ['ko', '한국어'], + ['id', 'Indonesia'], ['th', 'ไทย'] +]; +const RTL = new Set(['ar']); + +const $ = (id) => document.getElementById(id); +const input = $('n'); +const english = $('english'); +const error = $('error'); +const facts = $('facts'); +const langs = $('langs'); + +/** Turn the raw string into a number/BigInt plus flags, or null. */ +const interpret = (raw) => { + const str = raw.trim().replace(/[,_\s]/g, ''); + if (!/^-?\d+(\.\d+)?$/.test(str)) return null; + const negative = str.startsWith('-'); + const isDecimal = str.includes('.'); + const digits = str.replace('-', '').split('.')[0]; + let value; + if (isDecimal) value = Number(str); + else value = digits.length <= 15 ? Number(str) : BigInt(str); + return { str, value, negative, isDecimal, magnitude: digits.length }; +}; + +const row = (label, text) => { + const dt = document.createElement('dt'); + dt.textContent = label; + const dd = document.createElement('dd'); + if (text === false || text == null) { + dd.textContent = '—'; + dd.className = 'na'; + } else { + dd.textContent = text; + } + facts.append(dt, dd); +}; + +const render = (raw) => { + const parsed = interpret(raw); + facts.replaceChildren(); + langs.replaceChildren(); + + if (!parsed) { + english.textContent = ''; + error.hidden = raw.trim() === ''; + error.textContent = 'That is not a number I know how to say.'; + return; + } + + const words = numberstring(parsed.str); + if (words === false) { + english.textContent = ''; + error.hidden = false; + error.textContent = 'Too big. I stop at 10^36 − 1 (nine hundred ninety-nine decillion…).'; + return; + } + error.hidden = true; + english.textContent = words; + + const { value, negative, isDecimal } = parsed; + const wholeInt = !isDecimal && !negative; + const smallInt = wholeInt && typeof value === 'number'; + + row('comma', isDecimal ? false : comma(value)); + row('ordinal', wholeInt && value !== 0 && value !== 0n ? ordinal(value) : false); + row('roman', smallInt && value >= 1 && value <= 3999 ? roman(value) : false); + row('year', smallInt && value >= 1000 && value <= 9999 ? year(value) : false); + row('currency', !negative && typeof value === 'number' && value < 1e15 ? currency(`$${parsed.str}`) : false); + row('telephone', wholeInt && parsed.magnitude <= 15 ? telephone(parsed.str) : false); + row('fraction', smallInt && value >= 2 && value <= 1000 ? `1/${value} = ${fraction(1, value)}` : false); + row('title', numberstring(parsed.str, { cap: 'title' })); + row('shout', numberstring(parsed.str, { cap: 'upper', punc: '!' })); + + for (const [code, name] of LANGS) { + const tr = document.createElement('tr'); + const c = document.createElement('td'); + c.className = 'code'; + c.textContent = code; + const n = document.createElement('td'); + n.className = 'name'; + n.textContent = name; + const w = document.createElement('td'); + w.className = 'words'; + if (RTL.has(code)) w.dir = 'rtl'; + const out = numberstring(parsed.str, { lang: code }); + w.textContent = out === false ? '—' : out; + tr.append(c, n, w); + langs.append(tr); + } +}; + +input.addEventListener('input', () => render(input.value)); +document.querySelectorAll('.chips button').forEach((b) => { + b.addEventListener('click', () => { + input.value = b.dataset.n; + render(input.value); + input.focus(); + }); +}); + +const fromHash = decodeURIComponent(location.hash.slice(1)); +if (fromHash) input.value = fromHash; +render(input.value); +input.addEventListener('change', () => { + history.replaceState(null, '', input.value ? `#${encodeURIComponent(input.value)}` : ' '); +}); diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..0f95766 --- /dev/null +++ b/site/index.html @@ -0,0 +1,88 @@ + + + + + + numberstring + + + + + + + + + + + + + + + + + + + + +
+
+ +

numberstring

+

Number One Way to Makes Words from Numbers

+
+ +
+ + +
+ + + + + + +
+
+ +
+

forty-two

+ +
+ +
+

Also

+
+
+ +
+

In 22 languages

+ + +
+
+ +
+

+ npm install numberstring + · npm + · GitHub + · zero dependencies, runs entirely in your browser +

+

### # " "

+
+
+ + + diff --git a/site/og.svg b/site/og.svg new file mode 100644 index 0000000..511409a --- /dev/null +++ b/site/og.svg @@ -0,0 +1,10 @@ + + + numberstring + Number One Way to Makes Words from Numbers + 42 + forty-two + cuarenta y dos · quarante-deux · zweiundvierzig + 四十二 · сорок два · बयालीस · اثنان وأربعون + 22 languages · ordinals · Roman numerals · decimals · currency · BigInt to 10³⁶ · zero dependencies + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..3346dc7 --- /dev/null +++ b/site/style.css @@ -0,0 +1,142 @@ +:root { + --bg: #0f1115; + --panel: #171a21; + --fg: #e8e8e3; + --muted: #8b93a1; + --accent: #7ee787; + --accent-2: #f2cc60; + --border: #2a2f3a; + --error: #ff7b72; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #fbfbf8; + --panel: #ffffff; + --fg: #1b1f27; + --muted: #5d6571; + --accent: #1a7f37; + --accent-2: #9a6700; + --border: #e1e4e8; + --error: #cf222e; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + line-height: 1.5; +} + +main { + max-width: 960px; + margin: 0 auto; + padding: 24px 16px 64px; +} + +header { text-align: center; margin-bottom: 24px; } + +.ascii { + font-family: var(--mono); + font-size: clamp(4px, 1.1vw, 10px); + line-height: 1.15; + color: var(--accent); + margin: 0 auto 8px; + overflow: hidden; + white-space: pre; +} + +h1 { + font-family: var(--mono); + font-size: 2rem; + margin: 0; + letter-spacing: -0.02em; +} + +.tagline { color: var(--muted); margin: 4px 0 0; font-style: italic; } + +.input-row { margin: 24px 0; } + +#n { + width: 100%; + font-family: var(--mono); + font-size: clamp(1.4rem, 4vw, 2.4rem); + padding: 14px 18px; + background: var(--panel); + color: var(--fg); + border: 2px solid var(--border); + border-radius: 12px; + outline: none; +} +#n:focus { border-color: var(--accent); } + +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.chips button { + font-family: var(--mono); + font-size: 0.9rem; + padding: 6px 12px; + background: transparent; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; +} +.chips button:hover { color: var(--fg); border-color: var(--accent); } +.chips sup { font-size: 0.7em; } + +.hero { min-height: 4rem; margin: 8px 0 32px; } +.english { + font-size: clamp(1.4rem, 3.5vw, 2.2rem); + font-weight: 600; + margin: 0; + color: var(--accent); + overflow-wrap: anywhere; +} +.error { color: var(--error); font-family: var(--mono); margin: 0; } + +h2 { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + margin: 0 0 12px; +} + +.facts dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0 0 32px; +} +.facts dt { color: var(--muted); font-family: var(--mono); font-size: 0.9rem; } +.facts dd { margin: 0; overflow-wrap: anywhere; } +.facts dd.na { color: var(--muted); } + +table { width: 100%; border-collapse: collapse; } +td { padding: 8px 8px; border-top: 1px solid var(--border); vertical-align: top; } +td.code { font-family: var(--mono); color: var(--accent-2); width: 3ch; } +td.name { color: var(--muted); width: 11ch; white-space: nowrap; } +td.words { overflow-wrap: anywhere; } +td.words[dir="rtl"] { text-align: left; } + +footer { margin-top: 48px; color: var(--muted); font-size: 0.9rem; text-align: center; } +footer a { color: var(--fg); } +footer code { font-family: var(--mono); background: var(--panel); padding: 2px 6px; border-radius: 4px; } +.footnote { font-family: var(--mono); opacity: 0.5; } + +.visually-hidden { + position: absolute; width: 1px; height: 1px; overflow: hidden; + clip: rect(0 0 0 0); white-space: nowrap; +} + +@media (max-width: 520px) { + .facts dl { grid-template-columns: 1fr; gap: 2px; } + .facts dd { margin-bottom: 8px; } + td.name { display: none; } +} diff --git a/test/index.test.js b/test/index.test.js index bb64775..2c21015 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -78,9 +78,10 @@ describe('numberstring', () => { expect(numberstring(5000000000000000)).toBe('five quadrillion'); }); - it('returns false for regular numbers exceeding MAX_SAFE_INTEGER', () => { - // Regular numbers lose precision above MAX_SAFE_INTEGER - expect(numberstring(Number.MAX_SAFE_INTEGER + 1)).toBe(false); + it('widens regular numbers above MAX_SAFE_INTEGER to BigInt', () => { + // Precision is whatever the float holds; the value is spoken as-is + expect(numberstring(Number.MAX_SAFE_INTEGER + 1)).toBe(numberstring(BigInt(Number.MAX_SAFE_INTEGER) + 1n)); + expect(numberstring(1e21)).toBe('one sextillion'); }); it('handles MAX_SAFE_INTEGER', () => { @@ -134,9 +135,9 @@ describe('numberstring', () => { expect(result).toContain('sextillion'); }); - it('returns false for negative BigInts', () => { - expect(numberstring(-1n)).toBe(false); - expect(numberstring(-1000000000000000000n)).toBe(false); + it('converts negative BigInts', () => { + expect(numberstring(-1n)).toBe('negative one'); + expect(numberstring(-1000000000000000000n)).toBe('negative one quintillion'); }); it('returns false for BigInts exceeding decillions', () => { @@ -152,22 +153,21 @@ describe('numberstring', () => { }); describe('invalid inputs', () => { - it('returns false for non-numbers', () => { + it('returns false for non-numeric strings', () => { expect(numberstring('one')).toBe(false); - expect(numberstring('123')).toBe(false); + expect(numberstring('12abc')).toBe(false); + expect(numberstring('1e5')).toBe(false); + expect(numberstring('')).toBe(false); }); - it('returns false for NaN', () => { + it('returns false for NaN and Infinity', () => { expect(numberstring(NaN)).toBe(false); + expect(numberstring(Infinity)).toBe(false); + expect(numberstring(-Infinity)).toBe(false); }); - it('returns false for negative numbers', () => { - expect(numberstring(-1)).toBe(false); - expect(numberstring(-100)).toBe(false); - }); - - it('returns false for numbers exceeding MAX_SAFE_INTEGER', () => { - expect(numberstring(Number.MAX_SAFE_INTEGER + 1)).toBe(false); + it('converts numbers just past MAX_SAFE_INTEGER via BigInt', () => { + expect(numberstring(Number.MAX_SAFE_INTEGER + 1)).toBe(numberstring(BigInt(Number.MAX_SAFE_INTEGER) + 1n)); }); it('returns false for boolean values', () => { @@ -186,6 +186,68 @@ describe('numberstring', () => { }); }); + describe('forgiving input', () => { + it('converts negative numbers', () => { + expect(numberstring(-1)).toBe('negative one'); + expect(numberstring(-100)).toBe('negative one hundred'); + }); + + it('converts decimals', () => { + expect(numberstring(1.5)).toBe('one point five'); + expect(numberstring(-3.14)).toBe('negative three point one four'); + expect(numberstring(0.25, { point: 'dot' })).toBe('zero dot two five'); + }); + + it('converts numeric strings', () => { + expect(numberstring('42')).toBe('forty-two'); + expect(numberstring(' 1000 ')).toBe('one thousand'); + expect(numberstring('-7')).toBe('negative seven'); + expect(numberstring('3.5')).toBe('three point five'); + expect(numberstring('-0.5')).toBe('negative zero point five'); + }); + + it('widens large numbers to BigInt instead of rejecting them', () => { + expect(numberstring(1e21)).toBe('one sextillion'); + expect(numberstring(-1e21)).toBe('negative one sextillion'); + expect(numberstring(1e21, { lang: 'de' })).toBe('eine Trilliarde'); + expect(numberstring(Number.MAX_VALUE)).toBe(false); + }); + + it('converts long numeric strings as BigInt', () => { + expect(numberstring('1000000000000000000000')).toBe('one sextillion'); + expect(numberstring('-1000000000000000000')).toBe('negative one quintillion'); + }); + + it('honors the lang option', () => { + expect(numberstring(42, { lang: 'es' })).toBe('cuarenta y dos'); + expect(numberstring(42, { lang: 'FR' })).toBe('quarante-deux'); + expect(numberstring(42, { lang: 'en' })).toBe('forty-two'); + expect(numberstring(42, { lang: 'klingon' })).toBe('forty-two'); + expect(numberstring('42', { lang: 'de' })).toBe('zweiundvierzig'); + }); + + it('applies cap and punc once across delegated paths', () => { + expect(numberstring(-7, { cap: 'title', punc: '.' })).toBe('Negative Seven.'); + expect(numberstring(3.5, { cap: 'upper', punc: '!' })).toBe('THREE POINT FIVE!'); + expect(numberstring(42, { lang: 'de', cap: 'title', punc: '?' })).toBe('Zweiundvierzig?'); + expect(numberstring('-2.5', { punc: '!' })).toBe('negative two point five!'); + }); + + it('does not fall back to English for negatives or decimals in other languages', () => { + expect(numberstring(-5, { lang: 'es' })).toBe(false); + expect(numberstring(1.5, { lang: 'fr' })).toBe(false); + expect(numberstring('-2.5', { lang: 'de' })).toBe(false); + expect(numberstring(-1n, { lang: 'ru' })).toBe(false); + expect(numberstring(-5, { lang: 'en' })).toBe('negative five'); + }); + + it('still rejects values beyond 10^36', () => { + expect(numberstring(-1e37)).toBe(false); + expect(numberstring(1e37)).toBe(false); + expect(numberstring(-(10n ** 36n))).toBe(false); + }); + }); + describe('capitalization options', () => { it('applies lowercase (default behavior)', () => { expect(numberstring(500, { cap: 'lower' })).toBe('five hundred'); @@ -384,6 +446,21 @@ describe('decimal', () => { it('returns false for invalid input', () => { expect(decimal('abc')).toBe(false); expect(decimal(NaN)).toBe(false); + expect(decimal(Infinity)).toBe(false); + expect(decimal(null)).toBe(false); + }); + + it('expands exponent-form integers instead of truncating', () => { + expect(decimal(1e21)).toBe('one sextillion'); + expect(decimal(-1e21)).toBe('negative one sextillion'); + }); + + it('returns false for exponent-form fractions', () => { + expect(decimal(1.5e-7)).toBe(false); + }); + + it('keeps precision for long integer parts in strings', () => { + expect(decimal('1000000000000000000000.5')).toBe('one sextillion point five'); }); }); }); @@ -614,6 +691,14 @@ describe('parse', () => { expect(parse('')).toBe(false); expect(parse(123)).toBe(false); }); + + it('rejects adjacent simple words that are not tens + ones', () => { + expect(parse('nineteen eighty-four')).toBe(false); + expect(parse('ten five')).toBe(false); + expect(parse('one two')).toBe(false); + expect(parse('twenty two')).toBe(22); + expect(parse('two thousand twenty-four')).toBe(2024); + }); }); }); @@ -628,13 +713,23 @@ describe('spanish', () => { }); it('converts hundreds', () => { - expect(spanish(100)).toBe('ciento'); + expect(spanish(100)).toBe('cien'); + expect(spanish(101)).toBe('ciento uno'); expect(spanish(500)).toBe('quinientos'); + expect(spanish(100000)).toBe('cien mil'); }); it('converts thousands', () => { expect(spanish(1000)).toBe('mil'); expect(spanish(2000)).toBe('dos mil'); + expect(spanish(21000)).toBe('veintiún mil'); + }); + + it('apocopates uno before scale words', () => { + expect(spanish(1000000)).toBe('un millón'); + expect(spanish(2000000)).toBe('dos millones'); + expect(spanish(101000000)).toBe('ciento un millones'); + expect(spanish(21)).toBe('veintiuno'); }); it('applies capitalization', () => { diff --git a/test/languages.test.js b/test/languages.test.js new file mode 100644 index 0000000..1734f39 --- /dev/null +++ b/test/languages.test.js @@ -0,0 +1,510 @@ +import { describe, it, expect } from 'vitest'; +import { toWords } from '../index.js'; + +/** + * Per-language spot checks on the numbers that trip up converters: + * teens, 21/71/80/91 (French, Danish quirks), 100 vs 101 vs 110 (zero + * insertion, "cien"/"cem"), 1000 vs 1001 vs 2000 (plurals, cases), + * 21000 (apocope / gender before scale words), 100000 (lakh vs 十万), + * and 1 vs 2 vs 21 million (singular/dual/plural, gender agreement). + * + * Expected values were generated from the library and hand-reviewed. + * Cells flagged with a comment are plausible but unverified by a native + * speaker; if you know better, fix the language file and the expectation. + */ + +const CASES = { + // English + en: [ + [0, 'zero'], + [1, 'one'], + [11, 'eleven'], + [21, 'twenty-one'], + [71, 'seventy-one'], + [80, 'eighty'], + [91, 'ninety-one'], + [100, 'one hundred'], + [101, 'one hundred one'], + [110, 'one hundred ten'], + [200, 'two hundred'], + [1000, 'one thousand'], + [1001, 'one thousand one'], + [2000, 'two thousand'], + [21000, 'twenty-one thousand'], + [100000, 'one hundred thousand'], + [1000000, 'one million'], + [2000000, 'two million'], + [21000000, 'twenty-one million'], + ], + // Spanish + es: [ + [0, 'cero'], + [1, 'uno'], + [11, 'once'], + [21, 'veintiuno'], + [71, 'setenta y uno'], + [80, 'ochenta'], + [91, 'noventa y uno'], + [100, 'cien'], + [101, 'ciento uno'], + [110, 'ciento diez'], + [200, 'doscientos'], + [1000, 'mil'], + [1001, 'mil uno'], + [2000, 'dos mil'], + [21000, 'veintiún mil'], + [100000, 'cien mil'], + [1000000, 'un millón'], + [2000000, 'dos millones'], + [21000000, 'veintiún millones'], + ], + // French + fr: [ + [0, 'zéro'], + [1, 'un'], + [11, 'onze'], + [21, 'vingt et un'], + [71, 'soixante et onze'], + [80, 'quatre-vingts'], + [91, 'quatre-vingt-onze'], + [100, 'cent'], + [101, 'cent un'], + [110, 'cent dix'], + [200, 'deux cents'], + [1000, 'mille'], + [1001, 'mille un'], + [2000, 'deux mille'], + [21000, 'vingt et un mille'], + [100000, 'cent mille'], + [1000000, 'un million'], + [2000000, 'deux millions'], + [21000000, 'vingt et un millions'], + ], + // German + de: [ + [0, 'null'], + [1, 'eins'], + [11, 'elf'], + [21, 'einundzwanzig'], + [71, 'einundsiebzig'], + [80, 'achtzig'], + [91, 'einundneunzig'], + [100, 'einhundert'], + [101, 'einhunderteins'], + [110, 'einhundertzehn'], + [200, 'zweihundert'], + [1000, 'eintausend'], + [1001, 'eintausendeins'], + [2000, 'zweitausend'], + [21000, 'einundzwanzigtausend'], + [100000, 'einhunderttausend'], + [1000000, 'eine Million'], + [2000000, 'zwei Millionen'], + [21000000, 'einundzwanzig Millionen'], + ], + // Danish + // UNVERIFIED: Compound "ethundrede en" is understandable but spoken Danish prefers "hundrede og en"; "en"/"et" for 1 depends on gender + da: [ + [0, 'nul'], + [1, 'en'], + [11, 'elleve'], + [21, 'enogtyve'], + [71, 'enoghalvfjerds'], + [80, 'firs'], + [91, 'enoghalvfems'], + [100, 'ethundrede'], + [101, 'ethundrede en'], + [110, 'ethundrede ti'], + [200, 'tohundrede'], + [1000, 'ettusind'], + [1001, 'ettusind en'], + [2000, 'totusind'], + [21000, 'enogtyvetusind'], + [100000, 'ethundrede tusind'], + [1000000, 'en million'], + [2000000, 'to millioner'], + [21000000, 'enogtyve millioner'], + ], + // Chinese + zh: [ + [0, '零'], + [1, '一'], + [11, '十一'], + [21, '二十一'], + [71, '七十一'], + [80, '八十'], + [91, '九十一'], + [100, '一百'], + [101, '一百零一'], + [110, '一百一十'], + [200, '二百'], + [1000, '一千'], + [1001, '一千零一'], + [2000, '二千'], + [21000, '二万一千'], + [100000, '十万'], + [1000000, '一百万'], + [2000000, '二百万'], + [21000000, '二千一百万'], + ], + // Hindi + hi: [ + [0, 'शून्य'], + [1, 'एक'], + [11, 'ग्यारह'], + [21, 'इक्कीस'], + [71, 'इकहत्तर'], + [80, 'अस्सी'], + [91, 'इक्यानवे'], + [100, 'एक सौ'], + [101, 'एक सौ एक'], + [110, 'एक सौ दस'], + [200, 'दो सौ'], + [1000, 'एक हज़ार'], + [1001, 'एक हज़ार एक'], + [2000, 'दो हज़ार'], + [21000, 'इक्कीस हज़ार'], + [100000, 'एक लाख'], + [1000000, 'दस लाख'], + [2000000, 'बीस लाख'], + [21000000, 'दो करोड़ दस लाख'], + ], + // Russian + ru: [ + [0, 'ноль'], + [1, 'один'], + [11, 'одиннадцать'], + [21, 'двадцать один'], + [71, 'семьдесят один'], + [80, 'восемьдесят'], + [91, 'девяносто один'], + [100, 'сто'], + [101, 'сто один'], + [110, 'сто десять'], + [200, 'двести'], + [1000, 'одна тысяча'], + [1001, 'одна тысяча один'], + [2000, 'две тысячи'], + [21000, 'двадцать одна тысяча'], + [100000, 'сто тысяч'], + [1000000, 'один миллион'], + [2000000, 'два миллиона'], + [21000000, 'двадцать один миллион'], + ], + // Portuguese + pt: [ + [0, 'zero'], + [1, 'um'], + [11, 'onze'], + [21, 'vinte e um'], + [71, 'setenta e um'], + [80, 'oitenta'], + [91, 'noventa e um'], + [100, 'cem'], + [101, 'cento e um'], + [110, 'cento e dez'], + [200, 'duzentos'], + [1000, 'mil'], + [1001, 'mil e um'], + [2000, 'dois mil'], + [21000, 'vinte e um mil'], + [100000, 'cem mil'], + [1000000, 'um milhão'], + [2000000, 'dois milhões'], + [21000000, 'vinte e um milhões'], + ], + // Japanese + ja: [ + [0, 'ゼロ'], + [1, '一'], + [11, '十一'], + [21, '二十一'], + [71, '七十一'], + [80, '八十'], + [91, '九十一'], + [100, '百'], + [101, '百一'], + [110, '百十'], + [200, '二百'], + [1000, '千'], + [1001, '千一'], + [2000, '二千'], + [21000, '二万一千'], + [100000, '十万'], + [1000000, '百万'], + [2000000, '二百万'], + [21000000, '二千百万'], + ], + // Korean + ko: [ + [0, '영'], + [1, '일'], + [11, '십일'], + [21, '이십일'], + [71, '칠십일'], + [80, '팔십'], + [91, '구십일'], + [100, '백'], + [101, '백일'], + [110, '백십'], + [200, '이백'], + [1000, '천'], + [1001, '천일'], + [2000, '이천'], + [21000, '이만천'], + [100000, '십만'], + [1000000, '백만'], + [2000000, '이백만'], + [21000000, '이천백만'], + ], + // Arabic + ar: [ + [0, 'صفر'], + [1, 'واحد'], + [11, 'أحد عشر'], + [21, 'واحد وعشرون'], + [71, 'واحد وسبعون'], + [80, 'ثمانون'], + [91, 'واحد وتسعون'], + [100, 'مائة'], + [101, 'مائة وواحد'], + [110, 'مائة وعشرة'], + [200, 'مائتان'], + [1000, 'ألف'], + [1001, 'ألف وواحد'], + [2000, 'ألفان'], + [21000, 'واحد وعشرون ألف'], + [100000, 'مائة ألف'], + [1000000, 'مليون'], + [2000000, 'مليونان'], + [21000000, 'واحد وعشرون مليون'], + ], + // Italian + it: [ + [0, 'zero'], + [1, 'uno'], + [11, 'undici'], + [21, 'ventuno'], + [71, 'settantuno'], + [80, 'ottanta'], + [91, 'novantuno'], + [100, 'cento'], + [101, 'centouno'], + [110, 'centodieci'], + [200, 'duecento'], + [1000, 'mille'], + [1001, 'milleuno'], + [2000, 'duemila'], + [21000, 'ventunmila'], + [100000, 'centomila'], + [1000000, 'un milione'], + [2000000, 'due milioni'], + [21000000, 'ventuno milioni'], + ], + // Dutch + nl: [ + [0, 'nul'], + [1, 'een'], + [11, 'elf'], + [21, 'eenentwintig'], + [71, 'eenenzeventig'], + [80, 'tachtig'], + [91, 'eenennegentig'], + [100, 'honderd'], + [101, 'honderdeen'], + [110, 'honderdtien'], + [200, 'tweehonderd'], + [1000, 'duizend'], + [1001, 'duizendeen'], + [2000, 'tweeduizend'], + [21000, 'eenentwintigduizend'], + [100000, 'honderdduizend'], + [1000000, 'een miljoen'], + [2000000, 'twee miljoen'], + [21000000, 'eenentwintig miljoen'], + ], + // Turkish + tr: [ + [0, 'sıfır'], + [1, 'bir'], + [11, 'on bir'], + [21, 'yirmi bir'], + [71, 'yetmiş bir'], + [80, 'seksen'], + [91, 'doksan bir'], + [100, 'yüz'], + [101, 'yüz bir'], + [110, 'yüz on'], + [200, 'iki yüz'], + [1000, 'bin'], + [1001, 'bin bir'], + [2000, 'iki bin'], + [21000, 'yirmi bir bin'], + [100000, 'yüz bin'], + [1000000, 'bir milyon'], + [2000000, 'iki milyon'], + [21000000, 'yirmi bir milyon'], + ], + // Polish + pl: [ + [0, 'zero'], + [1, 'jeden'], + [11, 'jedenaście'], + [21, 'dwadzieścia jeden'], + [71, 'siedemdziesiąt jeden'], + [80, 'osiemdziesiąt'], + [91, 'dziewięćdziesiąt jeden'], + [100, 'sto'], + [101, 'sto jeden'], + [110, 'sto dziesięć'], + [200, 'dwieście'], + [1000, 'tysiąc'], + [1001, 'tysiąc jeden'], + [2000, 'dwa tysiące'], + [21000, 'dwadzieścia jeden tysięcy'], + [100000, 'sto tysięcy'], + [1000000, 'jeden milion'], + [2000000, 'dwa miliony'], + [21000000, 'dwadzieścia jeden milionów'], + ], + // Swedish + // UNVERIFIED: Standalone 1 is usually "ett", and 101 is commonly written "etthundraett" + sv: [ + [0, 'noll'], + [1, 'en'], + [11, 'elva'], + [21, 'tjugoen'], + [71, 'sjuttioen'], + [80, 'åttio'], + [91, 'nittioen'], + [100, 'etthundra'], + [101, 'etthundra en'], + [110, 'etthundra tio'], + [200, 'tvåhundra'], + [1000, 'ettusen'], + [1001, 'ettusen en'], + [2000, 'tvåtusen'], + [21000, 'tjugoentusen'], + [100000, 'etthundra tusen'], + [1000000, 'en miljon'], + [2000000, 'två miljoner'], + [21000000, 'tjugoen miljoner'], + ], + // Indonesian + id: [ + [0, 'nol'], + [1, 'satu'], + [11, 'sebelas'], + [21, 'dua puluh satu'], + [71, 'tujuh puluh satu'], + [80, 'delapan puluh'], + [91, 'sembilan puluh satu'], + [100, 'seratus'], + [101, 'seratus satu'], + [110, 'seratus sepuluh'], + [200, 'dua ratus'], + [1000, 'seribu'], + [1001, 'seribu satu'], + [2000, 'dua ribu'], + [21000, 'dua puluh satu ribu'], + [100000, 'seratus ribu'], + [1000000, 'satu juta'], + [2000000, 'dua juta'], + [21000000, 'dua puluh satu juta'], + ], + // Thai + th: [ + [0, 'ศูนย์'], + [1, 'หนึ่ง'], + [11, 'สิบเอ็ด'], + [21, 'ยี่สิบเอ็ด'], + [71, 'เจ็ดสิบเอ็ด'], + [80, 'แปดสิบ'], + [91, 'เก้าสิบเอ็ด'], + [100, 'หนึ่งร้อย'], + [101, 'หนึ่งร้อยเอ็ด'], + [110, 'หนึ่งร้อยสิบ'], + [200, 'สองร้อย'], + [1000, 'หนึ่งพัน'], + [1001, 'หนึ่งพันเอ็ด'], + [2000, 'สองพัน'], + [21000, 'สองหมื่นหนึ่งพัน'], + [100000, 'หนึ่งแสน'], + [1000000, 'หนึ่งล้าน'], + [2000000, 'สองล้าน'], + [21000000, 'ยี่สิบเอ็ดล้าน'], + ], + // Norwegian + // UNVERIFIED: Spoken Norwegian prefers "hundre og en" over "etthundre en" + no: [ + [0, 'null'], + [1, 'en'], + [11, 'elleve'], + [21, 'tjueen'], + [71, 'syttien'], + [80, 'åtti'], + [91, 'nittien'], + [100, 'etthundre'], + [101, 'etthundre en'], + [110, 'etthundre ti'], + [200, 'tohundre'], + [1000, 'ettusen'], + [1001, 'ettusen en'], + [2000, 'totusen'], + [21000, 'tjueentusen'], + [100000, 'etthundre tusen'], + [1000000, 'en million'], + [2000000, 'to millioner'], + [21000000, 'tjueen millioner'], + ], + // Finnish + fi: [ + [0, 'nolla'], + [1, 'yksi'], + [11, 'yksitoista'], + [21, 'kaksikymmentäyksi'], + [71, 'seitsemänkymmentäyksi'], + [80, 'kahdeksankymmentä'], + [91, 'yhdeksänkymmentäyksi'], + [100, 'sata'], + [101, 'satayksi'], + [110, 'satakymmenen'], + [200, 'kaksisataa'], + [1000, 'tuhat'], + [1001, 'tuhatyksi'], + [2000, 'kaksituhatta'], + [21000, 'kaksikymmentäyksituhatta'], + [100000, 'satatuhatta'], + [1000000, 'yksi miljoona'], + [2000000, 'kaksi miljoonaa'], + [21000000, 'kaksikymmentäyksi miljoonaa'], + ], + // Icelandic + is: [ + [0, 'núll'], + [1, 'einn'], + [11, 'ellefu'], + [21, 'tuttugu og einn'], + [71, 'sjötíu og einn'], + [80, 'áttatíu'], + [91, 'níutíu og einn'], + [100, 'eitt hundrað'], + [101, 'eitt hundrað einn'], + [110, 'eitt hundrað tíu'], + [200, 'tvö hundruð'], + [1000, 'eitt þúsund'], + [1001, 'eitt þúsund einn'], + [2000, 'tvö þúsund'], + [21000, 'tuttugu og eitt þúsund'], + [100000, 'eitt hundrað þúsund'], + [1000000, 'ein milljón'], + [2000000, 'tvær milljónir'], + [21000000, 'tuttugu og ein milljón'], + ], +}; + +describe.each(Object.entries(CASES))('language spot check: %s', (lang, cases) => { + it.each(cases)('%i', (n, expected) => { + expect(toWords(n, { lang })).toBe(expected); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 1fad8a2..4efb526 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -2,10 +2,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + exclude: ['**/node_modules/**', 'archive/**'], coverage: { provider: 'v8', reporter: ['text', 'lcov', 'html'], - exclude: ['vitest.config.js', 'eslint.config.js'] + exclude: ['vitest.config.js', 'eslint.config.js', 'archive/**', 'site/**', 'scripts/**', 'demo.js'] } } }); From da37f206bb71edb7478105f0b880924c928271ab Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:30:43 -0400 Subject: [PATCH 2/2] v1.2.0: Unicode styles, ancient numerals, British and, nth/compact, vinculum Roman, playground polish (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PNG social preview, og regen script, repo metadata - Render site/og.png from og.svg (Slack, X, LinkedIn ignore SVG og:image) - Point og:image / twitter:image at the PNG - Add npm run site:og (scripts/build-og.sh, headless Chrome) - GitHub repo description, homepage and topics set via gh repo edit * fix(site): restore exact ASCII art header from index.js * fix(site): stop per-line centering skewing the ASCII art * feat: vinculum notation in roman() up to 3,999,999,999 * style(site): space out roman numerals so vinculum bars stay distinct * feat: and option, nth(), compact(), fancy(), ancient numerals, formal zh/ja (v1.2.0) - British 'and' option on numberstring()/ordinal() - nth(): 1st 2nd 3rd 11th 112th - compact(): 1.5K 2.3B, digits and long options - fancy(): circled, superscript, subscript, fullwidth, bold, doublestruck, sans, monospace, keycap, braille digit styles - numerals.js: egyptian, babylonian, mayan, greek, tally - chinese()/japanese() formal option for 大写 / 大字 financial numerals - Playground rows for all of the above; ASCII art links home - Codex fixes: bahasa alias, converter option types, safe hash decode - Types, README, CHANGELOG 1.2.0, version bump; still zero dependencies * fix: address Codex review on 1.2.0 features - compact(): ignore leading zeros when picking a scale; keep 1000Dc at the upper bound instead of returning false - decimal(): forward the 'and' option to the integer part - chinese(): emit 零 between a higher myriad group and a lower group that starts below the thousands place (一万零一), formal and plain * chore: drop mayan() and tally(); no system font coverage on macOS * fix(site): show the fraction row for any denominator, not just up to 1000 * fix: compact() promotes sub-thousand values that round to 1000 into K * feat: nato()/icao/military radio numerals, morse(), telephone oh option * feat: scientific(), radix/binary/octal/hex, bytes(), clock(); emoji alias; playground tweaks - scientific(): exact mantissa, unicode/caret/e/words formats, digits rounding - radix() with binary/octal/hex helpers; prefix, upper, pad options - bytes(): decimal or binary units, long form - clock(): clock-face emoji for hours or H:MM - fancy('emoji') alias of keycap - Playground: fraction row shows words only, year row from 1, new rows * fix: Babylonian unit wedge is DIŠ (U+12079), not GESH2; nato() reads all-zero strings digit by digit * feat: cap casing styles (camel, pascal, snake, kebab, constant, dot, sentence); bits() split from bytes() * docs: full sweep for 1.2.0 (README at-a-glance table, AGENTS, CLAUDE, CONTRIBUTING, package metadata, site meta, OG image) * chore: keep .netlify/ out of the npm package * fix: nato() skips round folding on zero-padded input; bytes()/bits() keep guard digits when rounding; sync lockfile to 1.2.0 * site: single bytes row * fix: case negative phrases once; integer rounding in bytes()/bits(); CAP_STYLES lists aliases * feat: fancy('clock') maps each digit to a clock face; playground clocks row * feat: year() reads years beyond 9999 as cardinals, accepts BigInt * fix: comma()/group()/lang never throw; comma keeps decimals; add fuzz suite * fix: clamp digits options to finite integers; formal Japanese zero is 零 * test: non-finite digits falls back to the default --- .npmignore | 3 + AGENTS.md | 25 +- CHANGELOG.md | 36 ++- CLAUDE.md | 22 +- CONTRIBUTING.md | 26 +- README.md | 238 +++++++++++++++-- index.d.ts | 147 +++++++++-- index.js | 586 +++++++++++++++++++++++++++++++++++++++--- languages/index.js | 1 + languages/ja.js | 32 ++- languages/zh.js | 29 ++- numerals.js | 239 +++++++++++++++++ package-lock.json | 4 +- package.json | 41 ++- scripts/build-og.sh | 10 + scripts/build-site.js | 3 +- scripts/serve-site.js | 1 + site/app.js | 69 ++++- site/index.html | 34 +-- site/og.png | Bin 0 -> 66431 bytes site/og.svg | 2 +- site/style.css | 10 + test/extras.test.js | 585 +++++++++++++++++++++++++++++++++++++++++ test/fuzz.test.js | 50 ++++ test/index.test.js | 32 ++- 25 files changed, 2073 insertions(+), 152 deletions(-) create mode 100644 numerals.js create mode 100755 scripts/build-og.sh create mode 100644 site/og.png create mode 100644 test/extras.test.js create mode 100644 test/fuzz.test.js diff --git a/.npmignore b/.npmignore index 960a7e0..e038fe6 100644 --- a/.npmignore +++ b/.npmignore @@ -42,3 +42,6 @@ Thumbs.db # Dependencies node_modules/ + +# Netlify local link state +.netlify/ diff --git a/AGENTS.md b/AGENTS.md index 96623b4..65c2e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,13 +9,21 @@ ### Main Export: `numberstring(n, options)` - Converts a number to words; forgiving: accepts integers, negatives, decimals, numeric strings, BigInt - Returns `string` on success, `false` on invalid input -- Options: `{ cap: 'title'|'upper'|'lower', punc: '!'|'?'|'.', lang: 'es'|'fr'|..., point: 'point' }` +- Options: `cap` (title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot), `punc` ('!' '?' '.'), `lang`, `point`, `and` (British), `formal` (zh/ja) ### Named Exports -- `ordinal`, `decimal`, `currency`, `roman`, `parse`, `negative`, `fraction`, `year`, `telephone`, `percent`, `toWords` -- `comma(n)` - Format number with comma separators -- `group(n)` - Get magnitude group (0=ones, 1=thousands, 2=millions, etc.) -- One named export per language (`spanish`, `french`, ...) + +English words: `ordinal`, `nth`, `decimal`, `fraction`, `percent`, `currency`, `year`, `telephone`, `negative`, `parse`, `toWords` + +Spoken and coded: `nato` (aliases `icao`, `military`), `morse` + +Notation: `compact`, `scientific`, `comma`, `group`, `binary`, `octal`, `hex`, `radix`, `bytes`, `bits` + +Other numeral systems (`numerals.js`): `roman`, `greek`, `egyptian`, `babylonian`, `fancy`, `clock` + +Languages (cardinals, non-negative integers): `spanish`, `french`, `german`, `danish`, `chinese`, `hindi`, `russian`, `portuguese`, `japanese`, `korean`, `arabic`, `italian`, `dutch`, `turkish`, `polish`, `swedish`, `indonesian`, `thai`, `norwegian`, `finnish`, `icelandic`. `chinese` and `japanese` accept `{ formal: true }`; `spanish` and `portuguese` accept `{ cap }`. + +Constants: `CAP_STYLES`, `FANCY_STYLE_NAMES` ## Usage Examples @@ -50,10 +58,15 @@ npm run test:coverage # Run with coverage ## Code Architecture -- `index.js` - English core and all public helpers +- `index.js` - English core and all public helpers (ordinal, nato, scientific, bytes, ...) +- `numerals.js` - roman-adjacent systems (egyptian, babylonian, greek), `fancy()` digit styles, `clock()` - `languages/*.js` - one module per language; `test/languages.test.js` is the per-language spot-check table +- `test/extras.test.js` - tests for everything added in 1.2.0 +- `index.d.ts` - hand-written types; check with `npx tsc --noEmit --strict index.d.ts` - `site/` - playground; `scripts/build-site.js` stages the library into `site/lib/` - `archive/` - unmaintained code (old Express server), excluded from tests and lint - Pure functions, no side effects +- **Zero runtime dependencies.** Never add a package to `dependencies`. +- Only use Unicode blocks that macOS renders with system fonts (Mayan numerals and tally marks were dropped for this reason) - Frozen arrays for immutable word lists - Full JSDoc type documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index ce73f68..5de0d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-10-06 + +### Added + +- **Casing styles** - `cap` now also accepts `sentence`, `camel`, `pascal`, `snake`, `kebab` (alias `hyphen`), `constant` (alias `screaming`), and `dot`: `numberstring(123, { cap: 'snake' })` → `one_hundred_twenty_three`. Exported as `CAP_STYLES`. +- **British `and` option** - `numberstring(123, { and: true })` → "one hundred and twenty-three", `numberstring(1001, { and: true })` → "one thousand and one". Also honored by `ordinal()`. +- **`nth(n)`** - Numeric ordinal suffix: `1st`, `22nd`, `113th`. +- **`compact(n, opt)`** - `1.5K`, `2.3B`, `1Sx`, with `digits` and `long` ("1.5 million") options. +- **`fancy(n, style)`** - Digits in Unicode styles: circled ④②, superscript ⁴², subscript, fullwidth, bold, doublestruck 𝟜𝟚, sans, monospace, keycap 4️⃣2️⃣, braille ⠼⠙⠃. +- **Alternative numeral systems** in `numerals.js`: `egyptian()` hieroglyphs (to 9,999,999), `babylonian()` base-60 cuneiform, `greek()` Ionic letters (to 9999). Mayan numerals and tally marks were tried and dropped: no system font on macOS. +- **Financial numerals** - `chinese(n, { formal: true })` → 壹仟零壹 (大写), `japanese(n, { formal: true })` → 壱千壱 (大字). Also via `toWords(n, { lang: 'zh', formal: true })`. +- **`nato(n)`** (aliases `icao`, `military`) - ICAO radiotelephony numerals: `1984` → "wun niner ait fower", `2500` → "too tousand fife hundred", `121.5` → "wun too wun decimal fife". +- **`morse(n)`** - International Morse code digits. +- `telephone(n, { oh: true })` says "oh" for zero. +- **`scientific(n)`** - Exact-mantissa scientific notation: `1984` → "1.984 × 10³", with `caret`, `e`, and `words` formats and a `digits` option. +- **`binary()`, `octal()`, `hex()`, `radix(n, base)`** - Other bases with `prefix`, `upper`, `pad` options. +- **`bytes(n)`** and **`bits(n)`** - `1.5 KB`, `1.5 KiB`, `1.5 Mb`, or "one point five kilobytes". +- **`clock(time)`** - Clock-face emoji for an hour or `H:MM`. +- `fancy()` accepts `emoji` as an alias for `keycap`, and a `clock` style that turns each digit into a clock face (814 → 🕗🕐🕓). +- `roman()` now supports vinculum notation above 3999 (a bar multiplies by 1000, two bars by a million), up to 3,999,999,999. +- Open Graph and Twitter card tags on the playground, with a PNG preview image (`site/og.png`, rendered from `site/og.svg`). +- `comma()` keeps decimals (`1,234,567.89`) and accepts numeric strings. +- Fuzz test (`test/fuzz.test.js`) proves every public function returns a value, never throws, for hostile inputs and options. +- `year()` accepts years beyond 9999 (read as cardinals) and BigInt. +- `bahasa` accepted as an alias for Indonesian. + +### Fixed + +- Playground: ASCII art header restored to the exact index.js block and no longer skewed by per-line centering; a malformed URL hash no longer breaks the page. +- Type declarations: per-language converters no longer advertise a `cap` option they ignore (use `toWords()` for that); Spanish and Portuguese keep it, Chinese and Japanese gain `formal`. + +### Changed + +- Still zero runtime dependencies. + ## [1.1.0] - 2026-10-02 ### Added @@ -14,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Per-language spot-check tests** - `test/languages.test.js` locks in tricky numbers (21, 71, 80, 91, 100, 101, 1000, 1001, 2000, 21000, 1M, 2M, 21M) for all 22 languages. - **TypeScript declarations** - `index.d.ts` covering the default export, every helper, options, and the language functions. - Numbers above `Number.MAX_SAFE_INTEGER` (e.g. `1e21`) are widened to BigInt and converted instead of returning `false`. -- Open Graph and Twitter card tags on the playground. - `npm run site` and `npm run site:build` scripts. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6326f68..9e23735 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,9 +48,10 @@ Key constants: ## Layout - `index.js` - core English conversion plus all public helpers; `numberstring()` is forgiving and delegates to `negative()`, `decimal()`, `toWords()` +- `numerals.js` - alternative numeral systems (egyptian, babylonian, greek) and `fancy()` Unicode digit styles; table-driven, re-exported from index.js. Only add Unicode blocks that macOS renders out of the box (Mayan numerals and tally marks did not) - `languages/` - one module per language, cardinals only, non-negative integers only - `test/languages.test.js` - per-language spot-check table; update expectations when fixing a language -- `site/` - static playground deployed to Netlify (`netlify.toml`); `scripts/build-site.js` copies the library into `site/lib/` +- `site/` - static playground deployed to Netlify (`netlify.toml`); `scripts/build-site.js` copies the library into `site/lib/`. `og.png` is the social preview; after editing `og.svg` run `npm run site:og` to re-render it - `archive/server/` - old Express API, unmaintained, excluded from tests and lint; do not extend it ## Supported Languages @@ -68,15 +69,16 @@ Key constants: ## Features -- Number to words (cardinal) -- Ordinals (1st, 2nd, 3rd) -- Decimals (3.14 → "three point one four") -- Currency ($1.23 → "one dollar and twenty-three cents") -- Fractions (1/2 → "one half") -- Roman numerals (42 → "XLII") -- Negative numbers -- BigInt support up to 10^36 -- Forgiving input: `numberstring(-3.14)`, `numberstring('42')`, `numberstring(42, { lang: 'de' })` all work; invalid input returns `false` +Everything is exported from `index.js`; every function returns `string | false`. + +- English words: `numberstring` (cardinal, forgiving input), `ordinal`, `nth`, `decimal`, `fraction`, `percent`, `currency`, `year`, `telephone` (`oh` option), `negative`, `parse` (words → number) +- 22 languages via `toWords(n, { lang })` or the named exports; `chinese`/`japanese` take `formal` for 大写/大字 +- Spoken and coded: `nato` (`icao`, `military`), `morse` +- Notation: `compact` (1.5K), `scientific` (1.984 × 10³), `comma`, `binary`/`octal`/`hex`/`radix`, `bytes`/`bits` +- Other numeral systems: `roman` (vinculum above 3999), `greek`, `egyptian`, `babylonian`, `fancy` (circled, superscript, doublestruck, keycap/emoji, braille, ...), `clock` +- Options: `cap` casing styles (title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot), `punc`, `and` (British), `lang`, `point`, `formal` +- BigInt support up to 10^36; invalid input returns `false` +- **Zero runtime dependencies, always.** Never add a package to `dependencies`. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b04d92d..65b49e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,15 +21,15 @@ We'd love help adding more languages! Here's how: 1. Create a new file in `languages/` (e.g., `pt.js` for Portuguese) 2. Follow the pattern in `languages/en.js` -3. Export a default function that converts numbers to words -4. Add your language to `languages/index.js` -5. Add tests in `test/index.test.js` -6. Update README.md with the new language -7. Submit a PR! +3. Export a default function that converts non-negative integers to words +4. Add your language and its aliases to `languages/index.js`, re-export it from `index.js`, and add it to the `toWords` switch +5. Add a row to the spot-check table in `test/languages.test.js` and a named export in `index.d.ts` +6. Update README.md (feature list, language table, direct exports) and CHANGELOG.md +7. Submit a PR against `dev` ### Pull Request Process -1. Fork the repo and create your branch from `main` +1. Fork the repo and create your branch from `dev` (PRs target `dev`; `master` is the release branch) 2. Run `npm install` to install dependencies 3. Make your changes 4. Run `npm test` to ensure tests pass @@ -37,12 +37,22 @@ We'd love help adding more languages! Here's how: 6. Update documentation if needed 7. Submit your PR! +### Adding a New Conversion + +1. Add the function to `index.js` (or `numerals.js` for glyph-based systems) with JSDoc and an `@example` +2. Return `false` for input you cannot convert; never throw +3. Add it to the export block, `index.d.ts`, the README at-a-glance table and API section, and CHANGELOG.md +4. Add tests in `test/extras.test.js` +5. Add a row to the playground in `site/app.js` if it is worth seeing + ### Code Style -- ES2022+ syntax (const/let, arrow functions, async/await) +- ES2022+ syntax (const/let, arrow functions, template literals) - ESM modules only +- **Zero runtime dependencies.** Dev dependencies only. - Add JSDoc comments for public functions -- Maintain test coverage +- Maintain test coverage (`npm run test:coverage`) +- Unicode output must render with macOS system fonts out of the box ## Code of Conduct diff --git a/README.md b/README.md index 3db9a3c..a150dcb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ > Number One Way to Makes Words from Numbers -Transform any number into beautiful words. From `42` to `"forty-two"`, from `1000000` to `"one million"`. Supports **22 languages**, ordinals, currency, Roman numerals, and more! +Transform any number into beautiful words. From `42` to `"forty-two"`, from `1000000` to `"one million"`. Supports **22 languages**, ordinals, currency, Roman numerals, Egyptian hieroglyphs, Babylonian cuneiform, circled digits, and more! **Try it:** [numberstring.netlify.app](https://numberstring.netlify.app) — type a number, see it in 22 languages. Runs entirely in your browser. @@ -20,10 +20,12 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100 - **Zero dependencies** - Lightweight and fast - **22 languages** - English, Spanish, French, German, Danish, Chinese, Hindi, Russian, Portuguese, Japanese, Korean, Arabic, Italian, Dutch, Turkish, Polish, Swedish, Indonesian, Thai, Norwegian, Finnish, Icelandic - **Huge range** - Supports 0 to decillions (10^36) with BigInt -- **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers -- **Roman numerals** - Convert to and from Roman numerals +- **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers, NATO/ICAO radio numerals, Morse code, scientific notation, binary/hex, byte sizes, clock faces +- **Roman numerals** - Classic and vinculum notation to 3,999,999,999 +- **Ancient and alternative numerals** - Egyptian hieroglyphs, Babylonian cuneiform, Greek letters, Chinese/Japanese financial forms +- **Unicode digit styles** - ④② ⁴² 42 𝟜𝟚 4️⃣2️⃣ ⠼⠙⠃ - **Forgiving input** - Integers, negatives, decimals, numeric strings, BigInt. It just works -- **Well tested** - 660+ tests with 90%+ coverage, including per-language spot checks +- **Well tested** - 780+ tests with 90%+ coverage, per-language spot checks, and a fuzz suite proving nothing ever throws - **Modern ES modules** - Tree-shakeable, with bundled TypeScript declarations ## Installation @@ -43,11 +45,45 @@ numberstring(10n ** 18n); // 'one quintillion' (BigInt!) numberstring(-3.14); // 'negative three point one four' numberstring('1000'); // 'one thousand' numberstring(42, { lang: 'es' }); // 'cuarenta y dos' +numberstring(123, { and: true }); // 'one hundred and twenty-three' numberstring(123, { cap: 'title' }); // 'One Hundred Twenty-Three' ``` ## API Reference +Everything at a glance. Every function returns a `string`, or `false` when the input cannot be converted. + +| Function | Example | Result | +|----------|---------|--------| +| `numberstring(n, opt)` | `numberstring(-3.14)` | `negative three point one four` | +| `toWords(n, { lang })` | `toWords(42, { lang: 'es' })` | `cuarenta y dos` | +| `ordinal(n)` | `ordinal(21)` | `twenty-first` | +| `nth(n)` | `nth(22)` | `22nd` | +| `decimal(n)` | `decimal(3.14)` | `three point one four` | +| `fraction(a, b)` | `fraction(3, 4)` | `three quarters` | +| `percent(n)` | `percent(50)` | `fifty percent` | +| `currency(s)` | `currency('$1.50')` | `one dollar and fifty cents` | +| `year(n)` | `year(1984)` | `nineteen eighty-four` | +| `telephone(s, { oh })` | `telephone(8675309, { oh: true })` | `eight six seven five three oh nine` | +| `nato(n)` / `icao` / `military` | `nato(1984)` | `wun niner ait fower` | +| `morse(n)` | `morse(42)` | `....- ..---` | +| `compact(n)` | `compact(1500000)` | `1.5M` | +| `scientific(n)` | `scientific(1984)` | `1.984 × 10³` | +| `comma(n)` | `comma(1234567)` | `1,234,567` | +| `binary(n)` / `octal` / `hex` / `radix(n, base)` | `hex(255, { prefix: true })` | `0xff` | +| `bytes(n)` / `bits(n)` | `bytes(1536)` | `1.5 KB` | +| `roman(n)` | `roman(1999)` | `MCMXCIX` | +| `greek(n)` | `greek(42)` | `μβʹ` | +| `egyptian(n)` | `egyptian(42)` | `𓎆𓎆𓎆𓎆𓏺𓏺` | +| `babylonian(n)` | `babylonian(42)` | `𒌋𒌋𒌋𒌋𒁹𒁹` | +| `fancy(n, style)` | `fancy(42, 'doublestruck')` | `𝟜𝟚` | +| `clock(time)` | `clock('3:30')` | `🕞` | +| `parse(words)` | `parse('forty-two')` | `42` | +| `negative(n)` | `negative(-42)` | `negative forty-two` | + +Constants: `CAP_STYLES` (casing names for `cap`), `FANCY_STYLE_NAMES` (styles for `fancy`). Each language is also a named export (`spanish`, `french`, ... see below). + + ### Core Functions #### `numberstring(n, [options])` @@ -65,7 +101,19 @@ numberstring(100, { punc: '!' }); // 'one hundred!' numberstring('abc'); // false ``` -Negatives and decimals are English-only; with another `lang` they return `false` rather than falling back to English. +Every word-producing function takes `cap`, and it does more than capitalize: + +```javascript +numberstring(123, { cap: 'camel' }); // 'oneHundredTwentyThree' +numberstring(123, { cap: 'pascal' }); // 'OneHundredTwentyThree' +numberstring(123, { cap: 'snake' }); // 'one_hundred_twenty_three' +numberstring(123, { cap: 'kebab' }); // 'one-hundred-twenty-three' +numberstring(123, { cap: 'constant' }); // 'ONE_HUNDRED_TWENTY_THREE' +numberstring(123, { cap: 'dot' }); // 'one.hundred.twenty.three' +numberstring(123, { cap: 'sentence' }); // 'One hundred twenty-three' +``` + +Negatives and decimals are English-only; with another `lang` they return `false` rather than falling back to English. Pass `and: true` for British style ("one hundred and one", "one thousand and one"). #### `ordinal(n, [options])` @@ -110,7 +158,7 @@ Supported currencies: `$` `€` `£` `¥` `₹` `元` (USD, EUR, GBP, JPY, INR, #### `roman(n, [options])` -Convert to Roman numerals. +Convert to Roman numerals. Above 3999, vinculum notation puts a bar over a group to multiply it by 1000 (two bars for a million), reaching 3,999,999,999. ```javascript import { roman } from 'numberstring'; @@ -118,6 +166,8 @@ import { roman } from 'numberstring'; roman(42); // 'XLII' roman(1999); // 'MCMXCIX' roman(4, { lower: true }); // 'iv' +roman(4000); // 'I̅V̅' +roman(8675309); // 'V̿I̿I̿I̿D̅C̅L̅X̅X̅V̅CCCIX' ``` #### `parse(str)` @@ -132,6 +182,70 @@ parse('one thousand'); // 1000 parse('one quintillion'); // 1000000000000000000n (BigInt) ``` +#### `nth(n)` + +Numeric ordinal suffix. + +```javascript +import { nth } from 'numberstring'; + +nth(1); // '1st' +nth(22); // '22nd' +nth(113); // '113th' +``` + +#### `compact(n, [options])` + +Compact notation. + +```javascript +import { compact } from 'numberstring'; + +compact(1500); // '1.5K' +compact(2300000000); // '2.3B' +compact(999950); // '1M' +compact(1234567, { digits: 2 }); // '1.23M' +compact(1500000, { long: true }); // '1.5 million' +``` + +#### `fancy(n, [style])` + +Digits in a Unicode style: `circled` (default), `superscript`, `subscript`, `fullwidth`, `bold`, `doublestruck`, `sans`, `monospace`, `keycap` (alias `emoji`), `clock`, `braille`. + +```javascript +import { fancy } from 'numberstring'; + +fancy(42); // '④②' +fancy(42, 'superscript'); // '⁴²' +fancy(42, 'doublestruck'); // '𝟜𝟚' +fancy(42, 'keycap'); // '4️⃣2️⃣' +fancy(814, 'clock'); // '🕗🕐🕓' +fancy(-3.5, 'braille'); // '⠼⠤⠉⠨⠑' +``` + +### Ancient and Alternative Numerals + +All render with Unicode glyphs, so they need a font that covers the block (most modern systems do). + +```javascript +import { egyptian, babylonian, greek } from 'numberstring'; + +egyptian(42); // '𓎆𓎆𓎆𓎆𓏺𓏺' additive, 1 to 9,999,999 +babylonian(42); // '𒌋𒌋𒌋𒌋𒁹𒁹' base 60, places separated by spaces +babylonian(3600); // '𒁹 𒑊 𒑊' +greek(42); // 'μβʹ' Ionic letters, 1 to 9999 +greek(1999); // '͵αϡϟθʹ' +``` + +Chinese and Japanese also have the anti-fraud financial forms used on cheques: + +```javascript +import { chinese, japanese } from 'numberstring'; + +chinese(1001, { formal: true }); // '壹仟零壹' (大写) +japanese(1001, { formal: true }); // '壱千壱' (大字) +``` + ### Utility Functions #### `negative(n, [options])` @@ -164,9 +278,10 @@ Convert years to spoken form. ```javascript import { year } from 'numberstring'; -year(1984); // 'nineteen eighty-four' -year(2000); // 'two thousand' -year(2024); // 'twenty twenty-four' +year(1984); // 'nineteen eighty-four' +year(2000); // 'two thousand' +year(2024); // 'twenty twenty-four' +year(8675309); // 'eight million six hundred seventy-five thousand three hundred nine' ``` #### `telephone(phone, [options])` @@ -176,8 +291,86 @@ Convert phone numbers to words. ```javascript import { telephone } from 'numberstring'; -telephone('555-1234'); // 'five five five one two three four' -telephone(8675309); // 'eight six seven five three zero nine' +telephone('555-1234'); // 'five five five one two three four' +telephone(8675309); // 'eight six seven five three zero nine' +telephone(8675309, { oh: true }); // 'eight six seven five three oh nine' +``` + +#### `nato(n, [options])` + +ICAO / NATO radiotelephony numerals, the way pilots and air traffic control read numbers. Also exported as `icao` and `military`. + +```javascript +import { nato } from 'numberstring'; + +nato(1984); // 'wun niner ait fower' +nato(2500); // 'too tousand fife hundred' +nato('121.5'); // 'wun too wun decimal fife' +nato(2500, { digits: true }); // 'too fife zero zero' +``` + +#### `morse(n)` + +International Morse code for the digits. + +```javascript +import { morse } from 'numberstring'; + +morse(42); // '....- ..---' +morse(3.1); // '...-- .-.-.- .----' +``` + +#### `scientific(n, [options])` + +Scientific notation with an exact decimal mantissa. Formats: `unicode` (default), `caret`, `e`, `words`. + +```javascript +import { scientific } from 'numberstring'; + +scientific(1984); // '1.984 × 10³' +scientific(0.00042); // '4.2 × 10⁻⁴' +scientific(1984, { format: 'e' }); // '1.984e3' +scientific(1984, { digits: 3 }); // '1.98 × 10³' +scientific(1984, { format: 'words' }); // 'one point nine eight four times ten to the third' +``` + +#### `binary(n)`, `octal(n)`, `hex(n)`, `radix(n, base)` + +Integers in other bases, 2 to 36. + +```javascript +import { binary, hex, radix } from 'numberstring'; + +binary(42); // '101010' +hex(255, { prefix: true, upper: true }); // '0xFF' +binary(5, { pad: 8 }); // '00000101' +radix(42, 36); // '16' +``` + +#### `bytes(n, [options])` and `bits(n, [options])` + +Human-readable data sizes. Bytes use `KB`/`KiB`, bits use bandwidth-style `kb`/`Mb`. + +```javascript +import { bytes, bits } from 'numberstring'; + +bytes(1536); // '1.5 KB' +bytes(1536, { binary: true }); // '1.5 KiB' +bytes(1536, { long: true }); // 'one point five kilobytes' +bits(1500000); // '1.5 Mb' +bits(1500000, { long: true }); // 'one point five megabits' +``` + +#### `clock(time)` + +Clock-face emoji for an hour or an `H:MM` time, rounded to the half hour. + +```javascript +import { clock } from 'numberstring'; + +clock(3); // '🕒' +clock('3:30'); // '🕞' +clock(15); // '🕒' ``` #### `percent(pct, [options])` @@ -199,7 +392,8 @@ Format a number with comma separators. ```javascript import { comma } from 'numberstring'; -comma(1234567); // '1,234,567' +comma(1234567); // '1,234,567' +comma(1234567.89); // '1,234,567.89' ``` ## Multi-Language Support @@ -260,23 +454,37 @@ toWords(42, { lang: 'is' }); // 'fjörutíu og tveir' | `fi` | Finnish | neljäkymmentäkaksi | | `is` | Icelandic | fjörutíu og tveir | +### Direct language exports + +Each language is also exported by name for tree-shaking: `spanish`, `french`, `german`, `danish`, `chinese`, `hindi`, `russian`, `portuguese`, `japanese`, `korean`, `arabic`, `italian`, `dutch`, `turkish`, `polish`, `swedish`, `indonesian`, `thai`, `norwegian`, `finnish`, `icelandic`. They take a non-negative integer and return the cardinal words. Use `toWords()` or `numberstring()` with `lang` when you want `cap` and the other options. + +```javascript +import { french, japanese } from 'numberstring'; + +french(1984); // 'mille neuf cent quatre-vingt-quatre' +japanese(1984, { formal: true }); // '壱千九百八拾四' +``` + ### Adding a New Language Languages are modular! To add a new language: 1. Create `languages/xx.js` following the pattern in `languages/en.js` 2. Export your conversion function -3. Add to `languages/index.js` -4. Submit a PR! +3. Add to `languages/index.js`, re-export it from `index.js`, and add it to the `toWords` switch +4. Add a row to the table in `test/languages.test.js` and a named export in `index.d.ts` +5. Submit a PR! ## Options | Option | Type | Description | |--------|------|-------------| -| `cap` | `string` | Capitalization: `'title'`, `'upper'`, or `'lower'` | +| `cap` | `string` | Casing: `'title'`, `'upper'`, `'lower'`, `'sentence'`, `'camel'`, `'pascal'`, `'snake'`, `'kebab'`, `'constant'`, `'dot'` | | `punc` | `string` | Punctuation: `'!'`, `'?'`, or `'.'` | | `lang` | `string` | Language code for `numberstring()` and `toWords()` | | `point` | `string` | Word for decimal point (default: `'point'`) | +| `and` | `boolean` | British style: `one hundred and one` | +| `formal` | `boolean` | Chinese/Japanese financial numerals | | `lower` | `boolean` | Lowercase Roman numerals | ## Supported Scales diff --git a/index.d.ts b/index.d.ts index 3554c1f..62aae43 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,7 +3,12 @@ */ /** Capitalization styles */ -export type CapStyle = 'title' | 'upper' | 'lower'; +export type CapStyle = + | 'title' | 'upper' | 'lower' | 'sentence' + | 'camel' | 'pascal' | 'snake' | 'kebab' | 'hyphen' | 'constant' | 'screaming' | 'dot'; + +/** The casing styles the `cap` option accepts */ +export const CAP_STYLES: readonly CapStyle[]; /** Trailing punctuation */ export type Punc = '!' | '?' | '.'; @@ -27,7 +32,7 @@ export type Lang = | 'tr' | 'turkish' | 'türkçe' | 'pl' | 'polish' | 'polski' | 'sv' | 'swedish' | 'svenska' - | 'id' | 'indonesian' | 'bahasa' + | 'id' | 'indonesian' | 'bahasa' | 'bahasa indonesia' | 'th' | 'thai' | 'ไทย' | 'no' | 'norwegian' | 'norsk' | 'fi' | 'finnish' | 'suomi' @@ -35,7 +40,7 @@ export type Lang = | (string & {}); export interface Options { - /** Capitalization: 'title', 'upper', or 'lower' */ + /** Casing: title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot */ cap?: CapStyle; /** Trailing punctuation: '!', '?', or '.' */ punc?: Punc; @@ -43,6 +48,48 @@ export interface Options { lang?: Lang; /** Word for the decimal point (default 'point') */ point?: string; + /** British style: 'one hundred and twenty-three', 'one thousand and one' */ + and?: boolean; + /** Chinese/Japanese only: financial 大写 / 大字 numerals (壹贰叁, 壱弐参) */ + formal?: boolean; +} + +export interface CompactOptions { + /** Maximum decimal places (default 1, max 6) */ + digits?: number; + /** Spell the scale word: '1.5 million' instead of '1.5M' */ + long?: boolean; +} + + +/** Unicode digit styles accepted by fancy() */ +export type FancyStyle = + | 'circled' | 'superscript' | 'subscript' | 'fullwidth' | 'bold' + | 'doublestruck' | 'sans' | 'monospace' | 'keycap' | 'emoji' | 'clock' | 'braille'; + +export interface ScientificOptions extends Pick { + /** Maximum significant digits, rounds half up (default 12) */ + digits?: number; + /** 'unicode' (1.984 × 10³), 'caret' (1.984 × 10^3), 'e' (1.984e3), or 'words' */ + format?: 'unicode' | 'caret' | 'e' | 'words'; +} + +export interface RadixOptions { + /** Add 0b / 0o / 0x for bases 2, 8, 16 */ + prefix?: boolean; + /** Uppercase letter digits */ + upper?: boolean; + /** Left-pad with zeros to this many digits */ + pad?: number; +} + +export interface BytesOptions { + /** Use 1024 steps and KiB/MiB units */ + binary?: boolean; + /** Maximum decimal places (default 1) */ + digits?: number; + /** Spell it out: 'one point five kilobytes' / 'one point five megabits' */ + long?: boolean; } export interface CurrencyOptions extends Pick { @@ -75,10 +122,33 @@ declare function numberstring(n: Numeric, opt?: Options): Result; export default numberstring; /** Convert to words in any supported language (non-negative integers) */ -export function toWords(n: number | bigint, opt?: Pick): Result; +export function toWords(n: number | bigint, opt?: Pick): Result; /** Ordinal words: 1 → 'first', 21 → 'twenty-first' */ -export function ordinal(n: number | bigint, opt?: Pick): Result; +export function ordinal(n: number | bigint, opt?: Pick): Result; + +/** Numeric ordinal suffix: 1 → '1st', 22 → '22nd', 113 → '113th' */ +export function nth(n: Numeric): string | false; + +/** Compact notation: 1500 → '1.5K', 2300000000 → '2.3B' */ +export function compact(n: Numeric, opt?: CompactOptions): string | false; + +/** Digits in a Unicode style: fancy(42) → '④②', fancy(42, 'superscript') → '⁴²' */ +export function fancy(n: Numeric, style?: FancyStyle): string | false; + +/** The style names fancy() accepts */ +export const FANCY_STYLE_NAMES: readonly FancyStyle[]; + +/** Egyptian hieroglyphic numerals, 1 to 9,999,999 */ +export function egyptian(n: Numeric): string | false; + +/** Babylonian base-60 cuneiform numerals */ +export function babylonian(n: Numeric): string | false; + +/** Greek Ionic alphabetic numerals, 1 to 9999 */ +export function greek(n: Numeric): string | false; + + /** Decimal words: 3.14 → 'three point one four' */ export function decimal(n: number | string, opt?: Pick): Result; @@ -86,7 +156,7 @@ export function decimal(n: number | string, opt?: Pick /** Currency words: '$123.45' → 'one hundred twenty-three dollars and forty-five cents' */ export function currency(amount: number | string, opt?: CurrencyOptions): Result; -/** Roman numerals for 1–3999: 42 → 'XLII' */ +/** Roman numerals for 1–3,999,999,999: 42 → 'XLII'; above 3999 uses vinculum bars (4000 → 'I̅V̅') */ export function roman(n: number, opt?: RomanOptions): Result; /** Parse English words back to a number: 'forty-two' → 42. Returns BigInt above the safe integer range. */ @@ -98,11 +168,52 @@ export function negative(n: number | bigint, opt?: Pick): Result /** Fraction words: (1, 2) → 'one half', (3, 4) → 'three quarters' */ export function fraction(numerator: number, denominator: number, opt?: Pick): Result; -/** Year as spoken: 1984 → 'nineteen eighty-four' */ -export function year(y: number, opt?: Pick): Result; +/** Year as spoken: 1984 → 'nineteen eighty-four'; beyond 9999 reads as a cardinal */ +export function year(y: number | bigint, opt?: Pick): Result; + +export interface TelephoneOptions extends Pick { + /** Say 'oh' instead of 'zero' */ + oh?: boolean; +} /** Digits read individually: '555-1234' → 'five five five one two three four' */ -export function telephone(phone: number | string, opt?: Pick): Result; +export function telephone(phone: number | string, opt?: TelephoneOptions): Result; + +export interface NatoOptions extends Pick { + /** Always read digit by digit, even round hundreds and thousands */ + digits?: boolean; +} + +/** ICAO / NATO radiotelephony numerals: 1984 → 'wun niner ait fower', 2500 → 'too tousand fife hundred' */ +export function nato(n: Numeric, opt?: NatoOptions): Result; +/** Alias of nato() */ +export function icao(n: Numeric, opt?: NatoOptions): Result; +/** Alias of nato() */ +export function military(n: Numeric, opt?: NatoOptions): Result; + +/** International Morse code digits: 42 → '....- ..---' */ +export function morse(n: Numeric): string | false; + +/** Scientific notation with an exact mantissa: 1984 → '1.984 × 10³' */ +export function scientific(n: Numeric, opt?: ScientificOptions): string | false; + +/** Integer in another base, 2 to 36: radix(42, 16) → '2a' */ +export function radix(n: Numeric, base?: number, opt?: RadixOptions): string | false; +/** Binary: 42 → '101010' */ +export function binary(n: Numeric, opt?: RadixOptions): string | false; +/** Octal: 42 → '52' */ +export function octal(n: Numeric, opt?: RadixOptions): string | false; +/** Hexadecimal: 42 → '2a' */ +export function hex(n: Numeric, opt?: RadixOptions): string | false; + +/** Human-readable byte sizes: 1536 → '1.5 KB' */ +export function bytes(n: Numeric, opt?: BytesOptions): string | false; + +/** Human-readable bit counts: 1500000 → '1.5 Mb' */ +export function bits(n: Numeric, opt?: BytesOptions): string | false; + +/** Clock-face emoji for an hour (0-24) or 'H:MM': clock('3:30') → '🕞' */ +export function clock(time: number | string): string | false; /** Percent words: 50 → 'fifty percent' */ export function percent(pct: number | string, opt?: Pick): Result; @@ -113,18 +224,24 @@ export function comma(n: number | bigint): string | false; /** Magnitude group: 0 = ones, 1 = thousands, 2 = millions, ... */ export function group(n: number | bigint): number; -/** A single-language converter for non-negative integers */ -export type LanguageConverter = (n: number | bigint, opt?: Pick) => Result; +/** A single-language converter for non-negative integers. Use toWords() for `cap`. */ +export type LanguageConverter = (n: number | bigint) => Result; + +/** Spanish and Portuguese also accept `cap` directly */ +export type LanguageConverterWithCap = (n: number | bigint, opt?: Pick) => Result; + +/** Chinese and Japanese accept `formal` for 大写 / 大字 numerals */ +export type LanguageConverterWithFormal = (n: number | bigint, opt?: Pick) => Result; -export const spanish: LanguageConverter; +export const spanish: LanguageConverterWithCap; export const french: LanguageConverter; export const german: LanguageConverter; export const danish: LanguageConverter; -export const chinese: LanguageConverter; +export const chinese: LanguageConverterWithFormal; export const hindi: LanguageConverter; export const russian: LanguageConverter; -export const portuguese: LanguageConverter; -export const japanese: LanguageConverter; +export const portuguese: LanguageConverterWithCap; +export const japanese: LanguageConverterWithFormal; export const korean: LanguageConverter; export const arabic: LanguageConverter; export const italian: LanguageConverter; diff --git a/index.js b/index.js index e15680b..6536f5b 100644 --- a/index.js +++ b/index.js @@ -21,8 +21,12 @@ // Import language functions import { english, spanish, french, german, danish, chinese, hindi, russian, portuguese, japanese, korean, arabic, italian, dutch, turkish, polish, swedish, indonesian, thai, norwegian, finnish, icelandic, LANGUAGES } from './languages/index.js'; -// Re-export language functions +import { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock } from './numerals.js'; + +// Re-export language functions and alternative numeral systems export { spanish, french, german, danish, chinese, hindi, russian, portuguese, japanese, korean, arabic, italian, dutch, turkish, polish, swedish, indonesian, thai, norwegian, finnish, icelandic }; +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock }; +export { CAP_STYLES }; // ============================================================================ // CONSTANTS @@ -93,7 +97,11 @@ const MAX_VALUE = 10n ** 36n - 1n; // HELPER FUNCTIONS // ============================================================================ -const group = (n) => Math.ceil(n.toString().length / 3) - 1; +const group = (n) => { + if (typeof n !== 'number' && typeof n !== 'bigint') return false; + if (typeof n === 'number' && !Number.isFinite(n)) return false; + return Math.ceil((n < 0 ? -n : n).toString().length / 3) - 1; +}; const power = (g) => 10n ** BigInt(g * 3); const segment = (n, g) => n % power(g + 1); const hundment = (n, g) => Number(segment(n, g) / power(g)); @@ -113,7 +121,18 @@ const ten = (n) => { return `${TENS[Math.floor(n / 10)]} `; }; +/** Casing styles accepted by the `cap` option */ +const CAP_STYLES = Object.freeze(['title', 'upper', 'lower', 'sentence', 'camel', 'pascal', 'snake', 'kebab', 'hyphen', 'constant', 'screaming', 'dot']); + +const capFirst = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); + +/** + * Apply a casing style. Word-joining styles split on spaces and hyphens: + * 'one hundred twenty-three' → camel 'oneHundredTwentyThree', snake + * 'one_hundred_twenty_three', kebab 'one-hundred-twenty-three'. + */ const cap = (str, style) => { + const words = () => str.split(/[\s-]+/).filter(Boolean); switch (style) { case 'title': return str.replace(/\w([^-\s]*)/g, (txt) => @@ -123,6 +142,22 @@ const cap = (str, style) => { return str.toUpperCase(); case 'lower': return str.toLowerCase(); + case 'sentence': + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + case 'camel': + return words().map((w, i) => (i === 0 ? w.toLowerCase() : capFirst(w))).join(''); + case 'pascal': + return words().map(capFirst).join(''); + case 'snake': + return words().map((w) => w.toLowerCase()).join('_'); + case 'kebab': + case 'hyphen': + return words().map((w) => w.toLowerCase()).join('-'); + case 'constant': + case 'screaming': + return words().map((w) => w.toUpperCase()).join('_'); + case 'dot': + return words().map((w) => w.toLowerCase()).join('.'); default: return str; } @@ -138,8 +173,18 @@ const comma = (n) => { if (typeof n === 'bigint') { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } - if (isNaN(n)) return false; - return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + const [intPart, fracPart] = n.toString().split('.'); + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return fracPart === undefined ? grouped : `${grouped}.${fracPart}`; + } + if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) { + const [intPart, fracPart] = n.trim().split('.'); + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return fracPart === undefined ? grouped : `${grouped}.${fracPart}`; + } + return false; }; // ============================================================================ @@ -151,6 +196,7 @@ const comma = (n) => { * Internal: the public `string()` normalizes input and delegates here. * @param {number|bigint} n - The number to convert (0 to 10^36-1) * @param {Object} [opt] - Options object + * @param {boolean} [opt.and] - British "and" after hundreds / before a final group under 100 * @returns {string|false} The word representation or false if invalid */ const cardinal = (n, opt) => { @@ -169,15 +215,20 @@ const cardinal = (n, opt) => { let s = ''; + const useAnd = opt?.and === true; + if (num === 0n) { s = 'zero'; } else { for (let i = group(num); i >= 0; i--) { - s += hundred(hundment(num, i)); - s += ten(tenment(num, i)); - if (hundment(num, i) > 0) { - s += `${ILLIONS[i]} `; - } + const h = hundment(num, i); + if (h === 0) continue; + const t = tenment(num, i); + s += hundred(h); + // British style: "one hundred and one", "one thousand and one" + if (useAnd && t > 0 && (h >= 100 || (i === 0 && s))) s += 'and '; + s += ten(t); + s += `${ILLIONS[i]} `; } } @@ -200,14 +251,16 @@ const cardinal = (n, opt) => { * * @param {number|bigint|string} n - The number to convert * @param {Object} [opt] - Options object - * @param {string} [opt.cap] - Capitalization: 'title', 'upper', or 'lower' + * @param {string} [opt.cap] - Casing: 'title', 'upper', 'lower', 'sentence', 'camel', 'pascal', 'snake', 'kebab', 'constant', 'dot' * @param {string} [opt.punc] - Punctuation: '!', '?', or '.' * @param {string} [opt.lang] - Language code (default 'en') * @param {string} [opt.point] - Word for the decimal point (default 'point') + * @param {boolean} [opt.and] - British style: 'one hundred and twenty-three' * @returns {string|false} The word representation or false if invalid * * @example * numberstring(42) // 'forty-two' + * numberstring(123, { and: true }) // 'one hundred and twenty-three' * numberstring(-5) // 'negative five' * numberstring(3.14) // 'three point one four' * numberstring('1000') // 'one thousand' @@ -217,7 +270,7 @@ const string = (n, opt) => { let value = n; // Delegates apply `cap` themselves; `punc` is applied once in finish() const inner = opt ? { ...opt, punc: undefined } : opt; - const lang = opt?.lang?.toLowerCase(); + const lang = typeof opt?.lang === 'string' ? opt.lang.toLowerCase() : undefined; const foreign = Boolean(lang && LANGUAGES[lang] && LANGUAGES[lang] !== 'english'); if (typeof value === 'string') { @@ -289,7 +342,7 @@ const ordinal = (n, opt) => { s = `${TENS[tensDigit]}-${ORDINAL_ONES[onesDigit]}`; } } else { - const base = cardinal(num); + const base = cardinal(num, { and: opt?.and }); if (!base) return false; const parts = base.split(' '); @@ -358,7 +411,7 @@ const decimal = (n, opt) => { const intDigits = intPart || '0'; const intNum = intDigits.length <= 15 ? parseInt(intDigits, 10) : BigInt(intDigits); - const intWords = cardinal(intNum); + const intWords = cardinal(intNum, { and: opt?.and }); if (intWords === false) return false; let result = isNegative ? 'negative ' : ''; @@ -446,19 +499,52 @@ const currency = (amount, opt) => { // ROMAN NUMERAL FUNCTION // ============================================================================ -const roman = (n, opt) => { - if (typeof n !== 'number' || isNaN(n) || !Number.isInteger(n)) return false; - if (n < 1 || n > 3999) return false; - +/** Classic Roman numerals for 1-3999 */ +const romanBase = (n) => { let result = ''; let remaining = n; - for (const [value, numeral] of ROMAN_VALUES) { while (remaining >= value) { result += numeral; remaining -= value; } } + return result; +}; + +/** Combining marks for vinculum notation: one bar = x1000, two bars = x1000000 */ +const ROMAN_BARS = Object.freeze(['', '\u0305', '\u033F']); + +/** Maximum value expressible with a double vinculum (3,999,999,999) */ +const ROMAN_MAX = 3999999999; + +/** + * Convert to Roman numerals. Classic numerals cover 1-3999; above that, + * vinculum notation places a bar over a group to multiply it by 1000, so + * 4000 is I̅V̅ and 8675309 is V̿I̿I̿I̿D̅C̅L̅X̅X̅V̅CCCIX. + * @param {number} n - Integer from 1 to 3,999,999,999 + * @param {Object} [opt] - Options object + * @param {boolean} [opt.lower] - Return lowercase numerals + * @returns {string|false} The Roman numeral or false if out of range + */ +const roman = (n, opt) => { + if (typeof n !== 'number' || isNaN(n) || !Number.isInteger(n)) return false; + if (n < 1 || n > ROMAN_MAX) return false; + + let result = ''; + let remaining = n; + let level = 0; + + while (remaining > 0) { + // The topmost group keeps the classic form up to 3999; lower groups are 0-999 + const groupValue = remaining < 4000 ? remaining : remaining % 1000; + const bar = ROMAN_BARS[level]; + const letters = romanBase(groupValue); + const barred = bar ? [...letters].map((ch) => ch + bar).join('') : letters; + result = barred + result; + remaining = (remaining - groupValue) / 1000; + level++; + } return opt?.lower ? result.toLowerCase() : result; }; @@ -534,27 +620,436 @@ const parse = (str) => { }; // ============================================================================ -// ADDITIONAL UTILITY FUNCTIONS +// NTH (numeric ordinal suffix) // ============================================================================ -const negative = (n, opt) => { - if (typeof n === 'bigint') { - if (n >= 0n) return cardinal(n, opt); - const result = cardinal(-n, opt); - if (result === false) return false; - let s = `negative ${result}`; - if (opt?.cap) s = cap(s, opt.cap); - return s; +/** + * Append the English ordinal suffix to a number: 1st, 2nd, 3rd, 4th, 11th, 112th. + * @param {number|bigint|string} n - Integer (negatives keep their sign) + * @returns {string|false} + * + * @example + * nth(1) // '1st' + * nth(22) // '22nd' + * nth(113) // '113th' + */ +const nth = (n) => { + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && Number.isInteger(n) && Math.abs(n) <= Number.MAX_SAFE_INTEGER) value = BigInt(n); + else if (typeof n === 'string' && /^-?\d+$/.test(n.trim())) value = BigInt(n.trim()); + else return false; + + const abs = value < 0n ? -value : value; + const mod100 = Number(abs % 100n); + const mod10 = Number(abs % 10n); + let suffix = 'th'; + if (mod100 < 11 || mod100 > 13) { + if (mod10 === 1) suffix = 'st'; + else if (mod10 === 2) suffix = 'nd'; + else if (mod10 === 3) suffix = 'rd'; + } + return `${value}${suffix}`; +}; + +// ============================================================================ +// COMPACT (1.5K, 2.3M) +// ============================================================================ + +/** Short suffixes aligned with ILLIONS: thousand, million, billion, ... */ +const COMPACT_SUFFIXES = Object.freeze(['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc']); + +/** + * Compact notation: 1500 → '1.5K', 2300000 → '2.3M'. + * @param {number|bigint|string} n - The number + * @param {Object} [opt] - Options object + * @param {number} [opt.digits=1] - Maximum decimal places + * @param {boolean} [opt.long] - Spell the scale: '1.5 thousand' + * @returns {string|false} + * + * @example + * compact(1500) // '1.5K' + * compact(2300000000) // '2.3B' + * compact(999950) // '1M' + * compact(1500000, { long: true }) // '1.5 million' + */ +const compact = (n, opt) => { + let str; + if (typeof n === 'bigint') str = n.toString(); + else if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + str = Number.isInteger(n) ? BigInt(n).toString() : n.toString(); + if (str.includes('e')) return false; + } else if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) str = n.trim(); + else return false; + + const negative = str.startsWith('-'); + if (negative) str = str.slice(1); + const [rawInt, fracPart = ''] = str.split('.'); + // Leading zeros carry no magnitude: '0001000' is 1000 + const intPart = rawInt.replace(/^0+(?=\d)/, ''); + const digits = Number.isFinite(opt?.digits) ? Math.max(0, Math.min(Math.trunc(opt.digits), 6)) : 1; + + if (intPart.length < 4) { + const small = Number(`${intPart}.${fracPart || '0'}`); + const rounded = Number(small.toFixed(digits)); + // 999.99 rounds up to 1000, which belongs in the next scale + if (rounded < 1000) return `${negative ? '-' : ''}${rounded}`; + return `${negative ? '-' : ''}1${opt?.long ? ` ${ILLIONS[1]}` : COMPACT_SUFFIXES[1]}`; + } + + let g = Math.floor((intPart.length - 1) / 3); + if (g >= COMPACT_SUFFIXES.length) return false; + // Value / 10^(3g) as a float; precision loss is irrelevant at <= 6 decimals + const scaled = Number(`${intPart.slice(0, intPart.length - 3 * g)}.${intPart.slice(intPart.length - 3 * g)}${fracPart}`); + let value = Number(scaled.toFixed(digits)); + // Rounding can carry into the next scale; at the last scale keep 1000Dc + if (value >= 1000 && g + 1 < COMPACT_SUFFIXES.length) { + g++; + value = Number((value / 1000).toFixed(digits)); + } + + const scale = opt?.long ? ` ${ILLIONS[g]}` : COMPACT_SUFFIXES[g]; + return `${negative ? '-' : ''}${value}${scale}`; +}; + +// ============================================================================ +// NATO / ICAO PHONETIC NUMERALS +// ============================================================================ + +/** ICAO radiotelephony pronunciations for the digits */ +const NATO_DIGITS = Object.freeze(['zero', 'wun', 'too', 'tree', 'fower', 'fife', 'six', 'seven', 'ait', 'niner']); + +/** + * NATO / ICAO radiotelephony numerals. Digits are read one at a time + * (1984 → 'wun niner ait fower'); per ICAO, whole hundreds and thousands + * are read with 'hundred' and 'tousand' (2500 → 'too tousand fife hundred'). + * A decimal point is 'decimal', a negative sign 'minus'. + * @param {number|bigint|string} n - The number + * @param {Object} [opt] - Options object + * @param {boolean} [opt.digits] - Always read digit by digit, even round numbers + * @param {string} [opt.cap] - Capitalization: 'title', 'upper', or 'lower' + * @returns {string|false} + * + * @example + * nato(1984) // 'wun niner ait fower' + * nato(2500) // 'too tousand fife hundred' + * nato(3.14) // 'tree decimal wun fower' + */ +const nato = (n, opt) => { + let str; + if (typeof n === 'bigint') str = n.toString(); + else if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + str = Number.isInteger(n) ? BigInt(n).toString() : n.toString(); + if (str.includes('e')) return false; + } else if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) str = n.trim(); + else return false; + + const words = []; + if (str.startsWith('-')) { + words.push('minus'); + str = str.slice(1); + } + const [intPart, fracPart] = str.split('.'); + const spell = (digits) => [...digits].map((d) => NATO_DIGITS[Number(d)]); + + // Round hundreds / thousands: "fife hundred", "wun tousand", "too fife tousand" + const roundMatch = !opt?.digits && !fracPart && intPart.match(/^(\d{1,2})(\d?)(00)$/); + if (roundMatch && !intPart.startsWith('0') && intPart.length >= 3 && intPart.length <= 5) { + const thousands = intPart.slice(0, -3); + const hundredsDigit = intPart.slice(-3, -2); + if (thousands) words.push(...spell(thousands), 'tousand'); + if (hundredsDigit !== '0') words.push(NATO_DIGITS[Number(hundredsDigit)], 'hundred'); + } else { + words.push(...spell(intPart)); + if (fracPart) words.push('decimal', ...spell(fracPart)); + } + + let result = words.join(' '); + if (opt?.cap) result = cap(result, opt.cap); + return result; +}; + +// ============================================================================ +// SCIENTIFIC NOTATION +// ============================================================================ + +/** Expand a float's exponent form ('1.5e-7') into a plain decimal string */ +const expandExponent = (str) => { + const negative = str.startsWith('-'); + const [m, e] = (negative ? str.slice(1) : str).split('e'); + const exp = Number(e); + const mDigits = m.replace('.', ''); + const point = m.split('.')[0].length + exp; + let out; + if (point <= 0) out = `0.${'0'.repeat(-point)}${mDigits}`; + else if (point >= mDigits.length) out = `${mDigits}${'0'.repeat(point - mDigits.length)}`; + else out = `${mDigits.slice(0, point)}.${mDigits.slice(point)}`; + return (negative ? '-' : '') + out; +}; + +/** Normalize number | bigint | numeric string to a plain decimal string, or null */ +const toPlainDecimal = (n) => { + if (typeof n === 'bigint') return n.toString(); + if (typeof n === 'number') { + if (!Number.isFinite(n)) return null; + const str = n.toString(); + return str.includes('e') ? expandExponent(str) : str; + } + if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) return n.trim(); + return null; +}; + +/** + * Scientific notation with an exact decimal mantissa (no float drift). + * @param {number|bigint|string} n - The number + * @param {Object} [opt] - Options object + * @param {number} [opt.digits=12] - Maximum significant digits (rounds half up) + * @param {string} [opt.format='unicode'] - 'unicode' (1.984 × 10³), 'caret' (1.984 × 10^3), + * 'e' (1.984e3), or 'words' (one point nine eight four times ten to the third) + * @param {string} [opt.cap] - Capitalization for the words format + * @returns {string|false} + * + * @example + * scientific(1984) // '1.984 × 10³' + * scientific(0.00042) // '4.2 × 10⁻⁴' + * scientific(1984, { format: 'e' }) // '1.984e3' + * scientific(1984, { format: 'words' }) // 'one point nine eight four times ten to the third' + */ +const scientific = (n, opt) => { + let str = toPlainDecimal(n); + if (str === null) return false; + + const negative = str.startsWith('-'); + if (negative) str = str.slice(1); + const [rawInt, fracPart = ''] = str.split('.'); + const intPart = rawInt.replace(/^0+/, ''); + const all = intPart + fracPart; + const firstNonZero = all.search(/[1-9]/); + + let exponent = 0; + let sig = '0'; + if (firstNonZero !== -1) { + exponent = intPart ? intPart.length - 1 : -(firstNonZero + 1); + sig = all.slice(firstNonZero).replace(/0+$/, '') || '0'; + } + + const maxDigits = Number.isFinite(opt?.digits) ? Math.max(1, Math.min(Math.trunc(opt.digits), 36)) : 12; + if (sig.length > maxDigits) { + const rounded = BigInt(sig.slice(0, maxDigits)) + (Number(sig[maxDigits]) >= 5 ? 1n : 0n); + let roundedStr = rounded.toString(); + if (roundedStr.length > maxDigits) { + // 999 → 1000 carries into the exponent + exponent++; + roundedStr = roundedStr.slice(0, -1); + } + sig = roundedStr.replace(/0+$/, '') || '0'; + } + + const mantissa = sig.length > 1 ? `${sig[0]}.${sig.slice(1)}` : sig; + const sign = negative ? '-' : ''; + const format = opt?.format || 'unicode'; + + if (format === 'e') return `${sign}${mantissa}e${exponent}`; + if (format === 'caret') return `${sign}${mantissa} × 10^${exponent}`; + if (format === 'words') { + const mantissaWords = decimal(`${sign}${mantissa}`); + if (mantissaWords === false) return false; + let result = mantissaWords; + if (exponent !== 0) { + const power = exponent < 0 ? `negative ${ordinal(-exponent)}` : ordinal(exponent); + result += ` times ten to the ${power}`; + } + return opt?.cap ? cap(result, opt.cap) : result; + } + if (format !== 'unicode') return false; + return `${sign}${mantissa} × 10${fancy(exponent, 'superscript')}`; +}; + +// ============================================================================ +// RADIX (binary, octal, hex) +// ============================================================================ + +const RADIX_PREFIXES = Object.freeze({ 2: '0b', 8: '0o', 16: '0x' }); + +/** + * Integer in another base, 2 to 36. + * @param {number|bigint|string} n - Integer + * @param {number} [base=2] - Radix, 2 to 36 + * @param {Object} [opt] - Options object + * @param {boolean} [opt.prefix] - Add 0b / 0o / 0x for bases 2, 8, 16 + * @param {boolean} [opt.upper] - Uppercase letter digits + * @param {number} [opt.pad] - Left-pad with zeros to this many digits + * @returns {string|false} + * + * @example + * radix(42) // '101010' + * radix(42, 16) // '2a' + * radix(255, 16, { prefix: true, upper: true }) // '0xFF' + */ +const radix = (n, base = 2, opt) => { + if (!Number.isInteger(base) || base < 2 || base > 36) return false; + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && Number.isInteger(n) && Math.abs(n) <= Number.MAX_SAFE_INTEGER) value = BigInt(n); + else if (typeof n === 'string' && /^-?\d+$/.test(n.trim())) value = BigInt(n.trim()); + else return false; + + const negative = value < 0n; + let digits = (negative ? -value : value).toString(base); + if (opt?.upper) digits = digits.toUpperCase(); + if (opt?.pad) digits = digits.padStart(opt.pad, '0'); + const prefix = opt?.prefix ? (RADIX_PREFIXES[base] || '') : ''; + return `${negative ? '-' : ''}${prefix}${digits}`; +}; + +/** Binary: 42 → '101010' */ +const binary = (n, opt) => radix(n, 2, opt); +/** Octal: 42 → '52' */ +const octal = (n, opt) => radix(n, 8, opt); +/** Hexadecimal: 42 → '2a' */ +const hex = (n, opt) => radix(n, 16, opt); + +// ============================================================================ +// BYTES +// ============================================================================ + +const BYTE_UNITS = Object.freeze({ + decimal: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], + binary: ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'], + decimalWords: ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte', 'exabyte', 'zettabyte', 'yottabyte'], + binaryWords: ['byte', 'kibibyte', 'mebibyte', 'gibibyte', 'tebibyte', 'pebibyte', 'exbibyte', 'zebibyte', 'yobibyte'] +}); + +const BIT_UNITS = Object.freeze({ + decimal: ['b', 'kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'], + binary: ['b', 'Kib', 'Mib', 'Gib', 'Tib', 'Pib', 'Eib', 'Zib', 'Yib'], + decimalWords: ['bit', 'kilobit', 'megabit', 'gigabit', 'terabit', 'petabit', 'exabit', 'zettabit', 'yottabit'], + binaryWords: ['bit', 'kibibit', 'mebibit', 'gibibit', 'tebibit', 'pebibit', 'exbibit', 'zebibit', 'yobibit'] +}); + +/** Shared engine for bytes() and bits(): all rounding in BigInt, so precision holds at any size */ +const dataSize = (n, opt, table) => { + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && Number.isInteger(n) && n <= Number.MAX_SAFE_INTEGER) value = BigInt(n); + else if (typeof n === 'string' && /^\d+$/.test(n.trim())) value = BigInt(n.trim()); + else return false; + if (value < 0n) return false; + + const step = opt?.binary ? 1024n : 1000n; + const units = opt?.binary ? table.binary : table.decimal; + const words = opt?.binary ? table.binaryWords : table.decimalWords; + // Clamp to a finite integer 0-6 so BigInt() cannot throw on 1.5, NaN, or Infinity + const digits = Number.isFinite(opt?.digits) ? Math.max(0, Math.min(Math.trunc(opt.digits), 6)) : 1; + const precision = 10n ** BigInt(digits); + + let unit = 0; + let scale = 1n; + while (unit < units.length - 1 && value >= scale * step) { + scale *= step; + unit++; + } + // amount in units of 10^-digits, rounded half up + let fixed = (value * precision * 2n + scale) / (scale * 2n); + if (fixed >= step * precision && unit < units.length - 1) { + unit++; + scale *= step; + fixed = (value * precision * 2n + scale) / (scale * 2n); } - if (typeof n !== 'number' || isNaN(n)) return false; + const whole = (fixed / precision).toString(); + const frac = digits ? (fixed % precision).toString().padStart(digits, '0').replace(/0+$/, '') : ''; + const amountStr = frac ? `${whole}.${frac}` : whole; - if (n >= 0) return cardinal(n, opt); + if (opt?.long) { + const amountWords = frac ? decimal(amountStr) : cardinal(BigInt(whole)); + if (amountWords === false) return false; + const noun = amountStr === '1' ? words[unit] : `${words[unit]}s`; + return `${amountWords} ${noun}`; + } + return `${amountStr} ${units[unit]}`; +}; - const result = cardinal(Math.abs(n), opt); +/** + * Human-readable byte sizes. + * @param {number|bigint|string} n - Non-negative integer count of bytes + * @param {Object} [opt] - Options object + * @param {boolean} [opt.binary] - Use 1024 steps and KiB/MiB units + * @param {number} [opt.digits=1] - Maximum decimal places + * @param {boolean} [opt.long] - Spell it out: 'one point five kilobytes' + * @returns {string|false} + * + * @example + * bytes(1536) // '1.5 KB' + * bytes(1536, { binary: true }) // '1.5 KiB' + * bytes(1536, { long: true }) // 'one point five kilobytes' + */ +const bytes = (n, opt) => dataSize(n, opt, BYTE_UNITS); + +/** + * Human-readable bit counts (bandwidth style: kb, Mb, Gb). + * @param {number|bigint|string} n - Non-negative integer count of bits + * @param {Object} [opt] - Same options as bytes() + * @returns {string|false} + * + * @example + * bits(1500000) // '1.5 Mb' + * bits(1536, { binary: true }) // '1.5 Kib' + * bits(1500000, { long: true }) // 'one point five megabits' + */ +const bits = (n, opt) => dataSize(n, opt, BIT_UNITS); + +// ============================================================================ +// MORSE CODE +// ============================================================================ + +const MORSE_DIGITS = Object.freeze(['-----', '.----', '..---', '...--', '....-', '.....', '-....', '--...', '---..', '----.']); + +/** + * International Morse code for the digits. Digits are separated by a space, + * a decimal point is '.-.-.-' and a minus sign '-....-'. + * @param {number|bigint|string} n - The number + * @returns {string|false} + * + * @example + * morse(42) // '....- ..---' + * morse(3.1) // '...-- .-.-.- .----' + */ +const morse = (n) => { + let str; + if (typeof n === 'bigint') str = n.toString(); + else if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + str = Number.isInteger(n) ? BigInt(n).toString() : n.toString(); + if (str.includes('e')) return false; + } else if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) str = n.trim(); + else return false; + + return [...str].map((ch) => { + if (ch === '-') return '-....-'; + if (ch === '.') return '.-.-.-'; + return MORSE_DIGITS[Number(ch)]; + }).join(' '); +}; + + +const negative = (n, opt) => { + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && !isNaN(n)) value = n; + else return false; + + if (value >= 0) return cardinal(value, opt); + + // Case the whole phrase once; casing the inner words first would break camel/pascal + const inner = opt ? { ...opt, cap: undefined, punc: undefined } : opt; + const result = cardinal(typeof value === 'bigint' ? -value : Math.abs(value), inner); if (result === false) return false; let s = `negative ${result}`; if (opt?.cap) s = cap(s, opt.cap); + if (opt?.punc !== undefined) s = punc(s, opt.punc); return s; }; @@ -598,8 +1093,15 @@ const fraction = (numerator, denominator, opt) => { }; const year = (y, opt) => { + // Far-future years are read as plain cardinals: "the year ten thousand" + if (typeof y === 'bigint') { + if (y < 0n) return false; + if (y > 9999n) return cardinal(y, opt); + y = Number(y); + } if (typeof y !== 'number' || isNaN(y) || !Number.isInteger(y)) return false; - if (y < 0 || y > 9999) return false; + if (y < 0) return false; + if (y > 9999) return cardinal(y, opt); let result; @@ -637,7 +1139,8 @@ const telephone = (phone, opt) => { const phoneStr = String(phone).replace(/\D/g, ''); if (!phoneStr) return false; - const digitWords = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; + // 'oh' for zero is how English speakers usually read phone numbers aloud + const digitWords = [opt?.oh ? 'oh' : 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; const words = phoneStr.split('').map(d => digitWords[parseInt(d, 10)]); let result = words.join(' '); @@ -677,7 +1180,7 @@ const percent = (pct, opt) => { * @returns {string|false} The word representation */ const toWords = (n, opt) => { - const lang = opt?.lang?.toLowerCase() || 'en'; + const lang = typeof opt?.lang === 'string' ? opt.lang.toLowerCase() : 'en'; const langKey = LANGUAGES[lang] || 'english'; let result; @@ -698,7 +1201,7 @@ const toWords = (n, opt) => { result = danish(n); break; case 'chinese': - result = chinese(n); + result = chinese(n, opt); break; case 'hindi': result = hindi(n); @@ -710,7 +1213,7 @@ const toWords = (n, opt) => { result = portuguese(n); break; case 'japanese': - result = japanese(n); + result = japanese(n, opt); break; case 'korean': result = korean(n); @@ -772,5 +1275,18 @@ export { year, telephone, percent, + nth, + compact, + nato, + nato as icao, + nato as military, + morse, + scientific, + radix, + binary, + octal, + hex, + bytes, + bits, toWords }; diff --git a/languages/index.js b/languages/index.js index de73e7b..cf44cd7 100644 --- a/languages/index.js +++ b/languages/index.js @@ -90,6 +90,7 @@ const LANGUAGES = Object.freeze({ id: 'indonesian', indonesian: 'indonesian', 'bahasa indonesia': 'indonesian', + bahasa: 'indonesian', th: 'thai', thai: 'thai', 'ไทย': 'thai', diff --git a/languages/ja.js b/languages/ja.js index 73f9838..3025421 100644 --- a/languages/ja.js +++ b/languages/ja.js @@ -5,6 +5,8 @@ */ const JA_DIGITS = Object.freeze(['', '一', '二', '三', '四', '五', '六', '七', '八', '九']); +/** Daiji (大字) anti-fraud forms used on banknotes and legal documents */ +const JA_DAIJI = Object.freeze(['', '壱', '弐', '参', '四', '五', '六', '七', '八', '九']); const JA_SCALES = Object.freeze(['', '万', '億', '兆', '京', '垓', '𥝱', '穣', '溝']); /** Maximum supported value (10^36 - 1, up to 溝) */ @@ -16,8 +18,10 @@ const MAX_VALUE = 10n ** 36n - 1n; * @param {number} grp - The group value (0-9999) * @returns {string} The Japanese representation */ -const groupToJa = (grp, afterScale = false) => { +const groupToJa = (grp, afterScale = false, formal = false) => { if (grp === 0) return ''; + const digits = formal ? JA_DAIJI : JA_DIGITS; + const ten = formal ? '拾' : '十'; const thousands = Math.floor(grp / 1000); const hundreds = Math.floor((grp % 1000) / 100); @@ -28,35 +32,36 @@ const groupToJa = (grp, afterScale = false) => { // Thousands: 1 before 千 is omitted at the start (千) but kept after a // higher scale word (二万一千) + // Daiji always writes the 壱 (壱千, 壱百, 壱拾) if (thousands > 0) { - if (thousands === 1 && !afterScale) { + if (thousands === 1 && !afterScale && !formal) { result += '千'; } else { - result += JA_DIGITS[thousands] + '千'; + result += digits[thousands] + '千'; } } // Hundreds: 1 before 百 is omitted if (hundreds > 0) { - if (hundreds === 1) { + if (hundreds === 1 && !formal) { result += '百'; } else { - result += JA_DIGITS[hundreds] + '百'; + result += digits[hundreds] + '百'; } } // Tens: 1 before 十 is omitted if (tens > 0) { - if (tens === 1) { - result += '十'; + if (tens === 1 && !formal) { + result += ten; } else { - result += JA_DIGITS[tens] + '十'; + result += digits[tens] + ten; } } // Ones if (ones > 0) { - result += JA_DIGITS[ones]; + result += digits[ones]; } return result; @@ -65,6 +70,8 @@ const groupToJa = (grp, afterScale = false) => { /** * Convert a number to Japanese words * @param {number|bigint} n - The number to convert + * @param {Object} [opt] - Options object + * @param {boolean} [opt.formal] - Use daiji 大字 numerals (壱弐参, 拾) * @returns {string|false} The Japanese word representation * * @example @@ -72,7 +79,8 @@ const groupToJa = (grp, afterScale = false) => { * japanese(1000) // '千' * japanese(10000) // '一万' */ -const japanese = (n) => { +const japanese = (n, opt) => { + const formal = opt?.formal === true; let num; if (typeof n === 'bigint') { @@ -86,7 +94,7 @@ const japanese = (n) => { return false; } - if (num === 0n) return 'ゼロ'; + if (num === 0n) return formal ? '零' : 'ゼロ'; const str = num.toString(); const len = str.length; @@ -106,7 +114,7 @@ const japanese = (n) => { if (grp === 0) continue; - const grpStr = groupToJa(grp, i > 0); + const grpStr = groupToJa(grp, i > 0, formal); // 1 before 万 and above IS included (handled naturally by groupToJa // since grp=1 produces '一' for the ones digit in the group) diff --git a/languages/zh.js b/languages/zh.js index 95c89e7..6c3a28c 100644 --- a/languages/zh.js +++ b/languages/zh.js @@ -6,6 +6,9 @@ const ZH_ONES = Object.freeze(['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']); const ZH_UNITS = Object.freeze(['', '十', '百', '千']); +/** Financial (大写) anti-fraud forms used on cheques and contracts */ +const ZH_FORMAL_ONES = Object.freeze(['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']); +const ZH_FORMAL_UNITS = Object.freeze(['', '拾', '佰', '仟']); const ZH_ILLIONS = Object.freeze(['', '万', '亿', '兆', '京', '垓', '秭', '穰', '沟', '涧', '正', '载']); const MAX_VALUE = 10n ** 36n - 1n; @@ -13,14 +16,20 @@ const MAX_VALUE = 10n ** 36n - 1n; /** * Convert a number to Mandarin Chinese words * @param {number|bigint} n - The number to convert + * @param {Object} [opt] - Options object + * @param {boolean} [opt.formal] - Use financial 大写 numerals (壹贰叁, 拾佰仟) * @returns {string|false} The Mandarin word representation * * @example * chinese(42) // '四十二' * chinese(1000) // '一千' * chinese(10000) // '一万' + * chinese(42, { formal: true }) // '肆拾贰' */ -const chinese = (n) => { +const chinese = (n, opt) => { + const formal = opt?.formal === true; + const digitWords = formal ? ZH_FORMAL_ONES : ZH_ONES; + const unitWords = formal ? ZH_FORMAL_UNITS : ZH_UNITS; let num; if (typeof n === 'bigint') { @@ -71,27 +80,27 @@ const chinese = (n) => { let innerZero = false; if (thousands > 0) { - grpStr += ZH_ONES[thousands] + ZH_UNITS[3]; + grpStr += digitWords[thousands] + unitWords[3]; innerZero = false; } else if (result || i > 0) { innerZero = true; } if (hundreds > 0) { - if (innerZero && grpStr) grpStr += '零'; - grpStr += ZH_ONES[hundreds] + ZH_UNITS[2]; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; + grpStr += digitWords[hundreds] + unitWords[2]; innerZero = false; } else if (thousands > 0) { innerZero = true; } if (tens > 0) { - if (innerZero && grpStr) grpStr += '零'; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; // Special: 10-19 at start is just 十X, not 一十X - if (tens === 1 && !result && thousands === 0 && hundreds === 0) { - grpStr += ZH_UNITS[1]; + if (tens === 1 && !formal && !result && thousands === 0 && hundreds === 0) { + grpStr += unitWords[1]; } else { - grpStr += ZH_ONES[tens] + ZH_UNITS[1]; + grpStr += digitWords[tens] + unitWords[1]; } innerZero = false; } else if (hundreds > 0 || thousands > 0) { @@ -99,8 +108,8 @@ const chinese = (n) => { } if (ones > 0) { - if (innerZero && grpStr) grpStr += '零'; - grpStr += ZH_ONES[ones]; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; + grpStr += digitWords[ones]; } result += grpStr; diff --git a/numerals.js b/numerals.js new file mode 100644 index 0000000..a09a226 --- /dev/null +++ b/numerals.js @@ -0,0 +1,239 @@ +/** + * Alternative numeral systems and Unicode digit styles. + * Everything here is pure, table-driven, and renders with Unicode glyphs. + * Only blocks with broad system-font coverage are included (Mayan numerals + * and tally marks were dropped for lack of fonts on macOS). + * @module numerals + */ + +// ============================================================================ +// SHARED +// ============================================================================ + +/** Normalize a non-negative integer input to BigInt, or null if invalid */ +const toCount = (n) => { + if (typeof n === 'bigint') return n >= 0n ? n : null; + if (typeof n === 'number') return Number.isInteger(n) && n >= 0 ? BigInt(n) : null; + if (typeof n === 'string' && /^\d+$/.test(n.trim())) return BigInt(n.trim()); + return null; +}; + +/** Normalize any numeric input (sign, decimal point) to a digit string, or null */ +const toDigitString = (n) => { + if (typeof n === 'bigint') return n.toString(); + if (typeof n === 'number') { + if (!Number.isFinite(n)) return null; + const str = n.toString(); + if (str.includes('e')) return Number.isInteger(n) ? BigInt(n).toString() : null; + return str; + } + if (typeof n === 'string') { + const str = n.trim(); + return /^-?\d+(\.\d+)?$/.test(str) ? str : null; + } + return null; +}; + +// ============================================================================ +// UNICODE DIGIT STYLES +// ============================================================================ + +/** Digit tables: ten glyphs for 0-9, plus minus and point */ +const FANCY_STYLES = Object.freeze({ + circled: { digits: '⓪①②③④⑤⑥⑦⑧⑨', minus: '−', point: '·' }, + superscript: { digits: '⁰¹²³⁴⁵⁶⁷⁸⁹', minus: '⁻', point: '˙' }, + subscript: { digits: '₀₁₂₃₄₅₆₇₈₉', minus: '₋', point: '.' }, + fullwidth: { digits: '0123456789', minus: '-', point: '.' }, + bold: { digits: '𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', minus: '−', point: '.' }, + doublestruck: { digits: '𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡', minus: '−', point: '.' }, + sans: { digits: '𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫', minus: '−', point: '.' }, + monospace: { digits: '𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿', minus: '−', point: '.' }, + keycap: { digits: ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣'], minus: '➖', point: '.' }, + emoji: { digits: ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣'], minus: '➖', point: '.' }, + // Clock faces: 1-9 o'clock, with 12 o'clock standing in for 0 + clock: { digits: '🕛🕐🕑🕒🕓🕔🕕🕖🕗🕘', minus: '−', point: '·' }, + // Braille: numeric indicator ⠼ then a-j, decimal point ⠨, minus ⠤ + braille: { digits: '⠚⠁⠃⠉⠙⠑⠋⠛⠓⠊', minus: '⠤', point: '⠨', prefix: '⠼' } +}); + +/** Style names accepted by fancy() */ +const FANCY_STYLE_NAMES = Object.freeze(Object.keys(FANCY_STYLES)); + +/** + * Render a number's digits in a Unicode style. + * @param {number|bigint|string} n - The number + * @param {string} [style='circled'] - One of circled, superscript, subscript, + * fullwidth, bold, doublestruck, sans, monospace, keycap (alias emoji), clock, braille + * @returns {string|false} Styled digits or false if invalid + * + * @example + * fancy(42) // '④②' + * fancy(42, 'superscript') // '⁴²' + * fancy(-3.5, 'fullwidth') // '-3.5' + */ +const fancy = (n, style = 'circled') => { + const table = FANCY_STYLES[style]; + if (!table) return false; + const str = toDigitString(n); + if (str === null) return false; + + const glyphs = Array.isArray(table.digits) ? table.digits : [...table.digits]; + let out = table.prefix || ''; + for (const ch of str) { + if (ch === '-') out += table.minus; + else if (ch === '.') out += table.point; + else out += glyphs[Number(ch)]; + } + return out; +}; + +// ============================================================================ +// EGYPTIAN HIEROGLYPHS +// ============================================================================ + +/** Hieroglyphs for 1, 10, 100, ... 1,000,000 (stroke, heel bone, coil, lotus, finger, tadpole, Heh) */ +const EGYPTIAN_SYMBOLS = Object.freeze(['𓏺', '𓎆', '𓍢', '𓆼', '𓂭', '𓆐', '𓁨']); + +/** Largest value expressible with repeated hieroglyphs (9,999,999) */ +const EGYPTIAN_MAX = 9999999n; + +/** + * Egyptian hieroglyphic numerals, additive: each power of ten is a symbol + * repeated up to nine times, largest first. + * @param {number|bigint|string} n - Integer from 1 to 9,999,999 + * @returns {string|false} + * + * @example + * egyptian(42) // '𓎆𓎆𓎆𓎆𓏺𓏺' + * egyptian(1000) // '𓆼' + */ +const egyptian = (n) => { + const count = toCount(n); + if (count === null || count < 1n || count > EGYPTIAN_MAX) return false; + + const digits = count.toString(); + let out = ''; + for (let i = 0; i < digits.length; i++) { + const power = digits.length - 1 - i; + out += EGYPTIAN_SYMBOLS[power].repeat(Number(digits[i])); + } + return out; +}; + +// ============================================================================ +// BABYLONIAN CUNEIFORM +// ============================================================================ + +/** DIŠ (U+12079), the positional unit wedge; GESH2 (U+12415) looks alike but means sixty */ +const CUNEIFORM_ONE = '𒁹'; +const CUNEIFORM_TEN = '𒌋'; +/** Late Babylonian placeholder for an empty sexagesimal position */ +const CUNEIFORM_ZERO = '𒑊'; + +/** + * Babylonian sexagesimal (base 60) numerals. Each position is written with + * tens wedges then unit wedges; positions are separated by a space and an + * empty inner position uses the Late Babylonian placeholder sign. + * @param {number|bigint|string} n - Non-negative integer + * @returns {string|false} + * + * @example + * babylonian(42) // '𒌋𒌋𒌋𒌋𒁹𒁹' + * babylonian(3600) // '𒁹 𒑊 𒑊' + */ +const babylonian = (n) => { + const count = toCount(n); + if (count === null) return false; + if (count === 0n) return CUNEIFORM_ZERO; + + const places = []; + let rest = count; + while (rest > 0n) { + places.unshift(Number(rest % 60n)); + rest /= 60n; + } + return places + .map((p) => (p === 0 ? CUNEIFORM_ZERO : CUNEIFORM_TEN.repeat(Math.floor(p / 10)) + CUNEIFORM_ONE.repeat(p % 10))) + .join(' '); +}; + +// ============================================================================ +// CLOCK FACES +// ============================================================================ + +/** 🕐..🕛 for 1..12 o'clock (U+1F550..), 🕜..🕧 for the half hours (U+1F55C..) */ +const CLOCK_HOURS = Object.freeze([...'🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛']); +const CLOCK_HALVES = Object.freeze([...'🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧']); + +/** + * Clock-face emoji for an hour (0-24, 24-hour values wrap) or an "H:MM" time. + * Minutes round to the nearest half hour; 0 and 24 are 🕛. + * @param {number|string} time - Hour, or 'H:MM' + * @returns {string|false} + * + * @example + * clock(3) // '🕒' + * clock('3:30') // '🕞' + * clock(15) // '🕒' + * clock('23:50') // '🕛' + */ +const clock = (time) => { + let hour; + let minute = 0; + if (typeof time === 'number') { + if (!Number.isInteger(time) || time < 0 || time > 24) return false; + hour = time; + } else if (typeof time === 'string') { + const m = time.trim().match(/^(\d{1,2})(?::(\d{2}))?$/); + if (!m) return false; + hour = Number(m[1]); + minute = Number(m[2] || 0); + if (hour > 24 || minute > 59) return false; + } else { + return false; + } + + // Round to the nearest half hour; :45 and later roll to the next hour + if (minute >= 45) { + hour += 1; + minute = 0; + } + const half = minute >= 15; + const index = (hour + 11) % 12; + return half ? CLOCK_HALVES[index] : CLOCK_HOURS[index]; +}; + +// ============================================================================ +// GREEK (IONIC / MILESIAN) +// ============================================================================ + +const GREEK_ONES = Object.freeze(['', 'α', 'β', 'γ', 'δ', 'ε', 'ϛ', 'ζ', 'η', 'θ']); +const GREEK_TENS = Object.freeze(['', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ϟ']); +const GREEK_HUNDREDS = Object.freeze(['', 'ρ', 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', 'ϡ']); +/** Keraia marks a number; the lower keraia marks thousands */ +const GREEK_KERAIA = 'ʹ'; +const GREEK_THOUSANDS = '͵'; + +/** + * Greek alphabetic (Ionic) numerals for 1-9999, with stigma, koppa and sampi + * for 6, 90 and 900 and the keraia marks. + * @param {number|bigint|string} n - Integer from 1 to 9999 + * @returns {string|false} + * + * @example + * greek(42) // 'μβʹ' + * greek(1999) // '͵αϡϟθʹ' + */ +const greek = (n) => { + const count = toCount(n); + if (count === null || count < 1n || count > 9999n) return false; + + const v = Number(count); + const thousands = Math.floor(v / 1000); + const rest = v % 1000; + let out = thousands ? GREEK_THOUSANDS + GREEK_ONES[thousands] : ''; + out += GREEK_HUNDREDS[Math.floor(rest / 100)] + GREEK_TENS[Math.floor((rest % 100) / 10)] + GREEK_ONES[rest % 10]; + return out + GREEK_KERAIA; +}; + +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock }; diff --git a/package-lock.json b/package-lock.json index 5b4b0c4..980a1bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "numberstring", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "numberstring", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "devDependencies": { "@vitest/coverage-v8": "^4.0.18", diff --git a/package.json b/package.json index b6a61ef..a0bf815 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "numberstring", - "version": "1.1.0", - "description": "Number One Way to Makes Words from Numbers", + "version": "1.2.0", + "description": "Numbers to words in 22 languages, plus ordinals, Roman, NATO, Morse, scientific, hex, hieroglyphs and more. Zero dependencies.", "type": "module", "main": "index.js", "exports": { @@ -18,24 +18,45 @@ "lint": "eslint . --format stylish", "lint:report": "eslint . --format html -o coverage/lint-report.html", "site:build": "node scripts/build-site.js", - "site": "node scripts/build-site.js && node scripts/serve-site.js" + "site": "node scripts/build-site.js && node scripts/serve-site.js", + "site:og": "sh scripts/build-og.sh" }, "repository": { "type": "git", "url": "git+https://github.com/brianfunk/numberstring.git" }, "keywords": [ - "number", - "string", - "word", - "words", - "integer", "bigint", - "text", + "binary", + "braille", + "bytes", + "camelcase", + "compact", "convert", + "cuneiform", + "decillion", + "emoji", "english", + "hex", + "hieroglyphs", + "i18n", + "icao", + "integer", + "mayan", + "morse", + "multilingual", + "nato", + "number", + "number-to-words", + "ordinal", "quintillion", - "decillion" + "roman", + "scientific-notation", + "snake-case", + "string", + "text", + "word", + "words" ], "author": "Brian Funk", "license": "MIT", diff --git a/scripts/build-og.sh b/scripts/build-og.sh new file mode 100755 index 0000000..d143020 --- /dev/null +++ b/scripts/build-og.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +# Rasterize site/og.svg to site/og.png (1200x630) with headless Chrome. +# Run after editing og.svg: npm run site:og +set -e +cd "$(dirname "$0")/.." +CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" +[ -x "$CHROME" ] || CHROME="$(command -v google-chrome || command -v chromium || command -v chrome)" +"$CHROME" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \ + --window-size=1200,630 --screenshot="$PWD/site/og.png" "file://$PWD/site/og.svg" 2>/dev/null +echo "site/og.png rendered from site/og.svg" diff --git a/scripts/build-site.js b/scripts/build-site.js index 51ab049..b23922b 100644 --- a/scripts/build-site.js +++ b/scripts/build-site.js @@ -16,6 +16,7 @@ const lib = join(root, 'site', 'lib'); rmSync(lib, { recursive: true, force: true }); mkdirSync(lib, { recursive: true }); cpSync(join(root, 'index.js'), join(lib, 'index.js')); +cpSync(join(root, 'numerals.js'), join(lib, 'numerals.js')); cpSync(join(root, 'languages'), join(lib, 'languages'), { recursive: true }); -process.stdout.write('site/lib/ staged from index.js + languages/\n'); +process.stdout.write('site/lib/ staged from index.js + numerals.js + languages/\n'); diff --git a/scripts/serve-site.js b/scripts/serve-site.js index 56eeb1f..f9e980a 100644 --- a/scripts/serve-site.js +++ b/scripts/serve-site.js @@ -19,6 +19,7 @@ const TYPES = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', + '.png': 'image/png', '.ico': 'image/x-icon' }; diff --git a/site/app.js b/site/app.js index 0c9dd88..f2f69ac 100644 --- a/site/app.js +++ b/site/app.js @@ -1,5 +1,7 @@ import numberstring, { - comma, ordinal, roman, year, currency, telephone, fraction + comma, ordinal, roman, year, currency, telephone, fraction, + nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese, nato, morse, + scientific, binary, octal, hex, bytes, bits } from './lib/index.js'; const LANGS = [ @@ -32,10 +34,11 @@ const interpret = (raw) => { return { str, value, negative, isDecimal, magnitude: digits.length }; }; -const row = (label, text) => { +const row = (label, text, cls) => { const dt = document.createElement('dt'); dt.textContent = label; const dd = document.createElement('dd'); + if (cls) dd.classList.add(cls); if (text === false || text == null) { dd.textContent = '—'; dd.className = 'na'; @@ -45,6 +48,24 @@ const row = (label, text) => { facts.append(dt, dd); }; +/** Extra row for the Chinese 大写 / Japanese 大字 financial numerals */ +const formalRow = (code, value) => { + const tr = document.createElement('tr'); + const c = document.createElement('td'); + c.className = 'code'; + c.textContent = code; + const n = document.createElement('td'); + n.className = 'name'; + n.textContent = code === 'zh' ? '大写' : '大字'; + const w = document.createElement('td'); + w.className = 'words'; + const fn = code === 'zh' ? chinese : japanese; + const out = value === null ? false : fn(value, { formal: true }); + w.textContent = out === false ? '—' : out; + tr.append(c, n, w); + return tr; +}; + const render = (raw) => { const parsed = interpret(raw); facts.replaceChildren(); @@ -73,13 +94,41 @@ const render = (raw) => { row('comma', isDecimal ? false : comma(value)); row('ordinal', wholeInt && value !== 0 && value !== 0n ? ordinal(value) : false); - row('roman', smallInt && value >= 1 && value <= 3999 ? roman(value) : false); - row('year', smallInt && value >= 1000 && value <= 9999 ? year(value) : false); + row('roman', smallInt && value >= 1 && value <= 3999999999 ? roman(value) : false, 'roman'); + row('year', wholeInt && value >= 1 ? year(value) : false); row('currency', !negative && typeof value === 'number' && value < 1e15 ? currency(`$${parsed.str}`) : false); - row('telephone', wholeInt && parsed.magnitude <= 15 ? telephone(parsed.str) : false); - row('fraction', smallInt && value >= 2 && value <= 1000 ? `1/${value} = ${fraction(1, value)}` : false); + row('telephone', wholeInt && parsed.magnitude <= 15 ? telephone(parsed.str, { oh: true }) : false); + row('pilot', nato(parsed.str)); + row('scientific', scientific(parsed.str)); + row('binary', !isDecimal ? binary(value, { prefix: true }) : false, 'roman'); + row('octal', !isDecimal ? octal(value, { prefix: true }) : false, 'roman'); + row('hex', !isDecimal ? hex(value, { prefix: true }) : false, 'roman'); + row('bytes', wholeInt ? bytes(value) : false); + row('bits', wholeInt ? bits(value) : false); + row('morse', morse(parsed.str), 'roman'); + row('fraction', smallInt && value >= 2 ? fraction(1, value) : false); + row('british', wholeInt ? numberstring(value, { and: true }) : false); + row('nth', wholeInt ? nth(value) : false); + row('compact', compact(parsed.str)); row('title', numberstring(parsed.str, { cap: 'title' })); + row('sentence', numberstring(parsed.str, { cap: 'sentence', punc: '.' })); row('shout', numberstring(parsed.str, { cap: 'upper', punc: '!' })); + row('camelCase', numberstring(parsed.str, { cap: 'camel' }), 'roman'); + row('PascalCase', numberstring(parsed.str, { cap: 'pascal' }), 'roman'); + row('snake_case', numberstring(parsed.str, { cap: 'snake' }), 'roman'); + row('kebab-case', numberstring(parsed.str, { cap: 'kebab' }), 'roman'); + row('CONSTANT', numberstring(parsed.str, { cap: 'constant' }), 'roman'); + row('dot.case', numberstring(parsed.str, { cap: 'dot' }), 'roman'); + row('circled', fancy(parsed.str, 'circled')); + row('superscript', fancy(parsed.str, 'superscript')); + row('fullwidth', fancy(parsed.str, 'fullwidth')); + row('doublestruck', fancy(parsed.str, 'doublestruck')); + row('emoji', fancy(parsed.str, 'emoji')); + row('clocks', fancy(parsed.str, 'clock'), 'glyphs'); + row('braille', fancy(parsed.str, 'braille')); + row('egyptian', wholeInt ? egyptian(value) : false, 'glyphs'); + row('babylonian', wholeInt ? babylonian(value) : false, 'glyphs'); + row('greek', wholeInt ? greek(value) : false, 'glyphs'); for (const [code, name] of LANGS) { const tr = document.createElement('tr'); @@ -96,6 +145,7 @@ const render = (raw) => { w.textContent = out === false ? '—' : out; tr.append(c, n, w); langs.append(tr); + if (code === 'zh' || code === 'ja') langs.append(formalRow(code, wholeInt ? value : null)); } }; @@ -108,7 +158,12 @@ document.querySelectorAll('.chips button').forEach((b) => { }); }); -const fromHash = decodeURIComponent(location.hash.slice(1)); +let fromHash = ''; +try { + fromHash = decodeURIComponent(location.hash.slice(1)); +} catch { + fromHash = ''; +} if (fromHash) input.value = fromHash; render(input.value); input.addEventListener('change', () => { diff --git a/site/index.html b/site/index.html index 0f95766..c51d1c0 100644 --- a/site/index.html +++ b/site/index.html @@ -4,40 +4,42 @@ numberstring - + - - - + + + - +
+ + /## /"" /"" + | ## | "" |__/ + /####### /## /## /######/#### | ####### /###### /###### /""""""" /"""""" /"""""" /"" /""""""" /"""""" +| ##__ ##| ## | ##| ##_ ##_ ##| ##__ ## /##__ ## /##__ ## /""_____/|_ ""_/ /""__ ""| ""| ""__ "" /""__ "" +| ## \ ##| ## | ##| ## \ ## \ ##| ## \ ##| ########| ## \__/| """""" | "" | "" \__/| ""| "" \ ""| "" \ "" +| ## | ##| ## | ##| ## | ## | ##| ## | ##| ##_____/| ## \____ "" | "" /""| "" | ""| "" | ""| "" | "" +| ## | ##| ######/| ## | ## | ##| #######/| #######| ## /"""""""/ | """"/| "" | ""| "" | ""| """"""" +|__/ |__/ \______/ |__/ |__/ |__/|_______/ \_______/|__/ |_______/ \___/ |__/ |__/|__/ |__/ \____ "" + /"" \ "" + | """"""/ + \______/ +

numberstring

Number One Way to Makes Words from Numbers

diff --git a/site/og.png b/site/og.png new file mode 100644 index 0000000000000000000000000000000000000000..7d0e309000803ab6a69711fa2bc44ab39b4b9016 GIT binary patch literal 66431 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

C2J2#$0hmt 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 m*uQ?#0Bp^F^`Gforty-two cuarenta y dos · quarante-deux · zweiundvierzig 四十二 · сорок два · बयालीस · اثنان وأربعون - 22 languages · ordinals · Roman numerals · decimals · currency · BigInt to 10³⁶ · zero dependencies + 22 languages · ordinals · NATO · Morse · scientific · hex · Roman · hieroglyphs · emoji · zero dependencies diff --git a/site/style.css b/site/style.css index 3346dc7..ed13e4a 100644 --- a/site/style.css +++ b/site/style.css @@ -50,6 +50,10 @@ header { text-align: center; margin-bottom: 24px; } margin: 0 auto 8px; overflow: hidden; white-space: pre; + /* header is centered; a pre would center each line separately and skew the art */ + text-align: left; + width: max-content; + max-width: 100%; } h1 { @@ -117,6 +121,12 @@ h2 { .facts dt { color: var(--muted); font-family: var(--mono); font-size: 0.9rem; } .facts dd { margin: 0; overflow-wrap: anywhere; } .facts dd.na { color: var(--muted); } +/* vinculum bars need room so adjacent overlines don't merge */ +.facts dd.roman { font-family: var(--mono); letter-spacing: 0.12em; } +/* historical glyph blocks render small in most fonts */ +.facts dd.glyphs { font-size: 1.35em; line-height: 1.2; letter-spacing: 0.05em; } +header a.home { color: inherit; text-decoration: none; display: inline-block; } +header a.home:hover .ascii { color: var(--accent-2); } table { width: 100%; border-collapse: collapse; } td { padding: 8px 8px; border-top: 1px solid var(--border); vertical-align: top; } diff --git a/test/extras.test.js b/test/extras.test.js new file mode 100644 index 0000000..071d1d3 --- /dev/null +++ b/test/extras.test.js @@ -0,0 +1,585 @@ +import { describe, it, expect } from 'vitest'; +import numberstring, { + ordinal, nth, compact, fancy, FANCY_STYLE_NAMES, nato, icao, military, morse, telephone, + scientific, radix, binary, octal, hex, bytes, bits, clock, CAP_STYLES, roman, decimal, currency, year, + egyptian, babylonian, greek, + chinese, japanese, toWords +} from '../index.js'; + +describe('and option (British style)', () => { + it('inserts and after hundreds', () => { + expect(numberstring(123, { and: true })).toBe('one hundred and twenty-three'); + expect(numberstring(101, { and: true })).toBe('one hundred and one'); + expect(numberstring(100, { and: true })).toBe('one hundred'); + }); + + it('inserts and before a final group under one hundred', () => { + expect(numberstring(1001, { and: true })).toBe('one thousand and one'); + expect(numberstring(2000001, { and: true })).toBe('two million and one'); + expect(numberstring(1101, { and: true })).toBe('one thousand one hundred and one'); + }); + + it('does not add and where nothing follows', () => { + expect(numberstring(1000000, { and: true })).toBe('one million'); + expect(numberstring(1000, { and: true })).toBe('one thousand'); + expect(numberstring(42, { and: true })).toBe('forty-two'); + }); + + it('is off by default', () => { + expect(numberstring(123)).toBe('one hundred twenty-three'); + expect(numberstring(1001)).toBe('one thousand one'); + }); + + it('applies to the integer part of decimals', () => { + expect(numberstring('123.4', { and: true })).toBe('one hundred and twenty-three point four'); + expect(numberstring(1001.5, { and: true })).toBe('one thousand and one point five'); + }); + + it('flows through negatives and ordinals', () => { + expect(numberstring(-1001, { and: true })).toBe('negative one thousand and one'); + expect(ordinal(101, { and: true })).toBe('one hundred and first'); + expect(numberstring(1001, { and: true, cap: 'title' })).toBe('One Thousand And One'); + }); +}); + +describe('nth', () => { + it('uses st, nd, rd, th', () => { + expect(nth(1)).toBe('1st'); + expect(nth(2)).toBe('2nd'); + expect(nth(3)).toBe('3rd'); + expect(nth(4)).toBe('4th'); + expect(nth(0)).toBe('0th'); + }); + + it('handles the teens', () => { + expect(nth(11)).toBe('11th'); + expect(nth(12)).toBe('12th'); + expect(nth(13)).toBe('13th'); + expect(nth(111)).toBe('111th'); + expect(nth(112)).toBe('112th'); + expect(nth(113)).toBe('113th'); + }); + + it('handles larger numbers', () => { + expect(nth(21)).toBe('21st'); + expect(nth(22)).toBe('22nd'); + expect(nth(23)).toBe('23rd'); + expect(nth(101)).toBe('101st'); + expect(nth(1000)).toBe('1000th'); + }); + + it('accepts strings, BigInt, and negatives', () => { + expect(nth('42')).toBe('42nd'); + expect(nth(10n ** 20n + 1n)).toBe('100000000000000000001st'); + expect(nth(-1)).toBe('-1st'); + }); + + it('rejects invalid input', () => { + expect(nth(1.5)).toBe(false); + expect(nth('abc')).toBe(false); + expect(nth(NaN)).toBe(false); + expect(nth(null)).toBe(false); + }); +}); + +describe('compact', () => { + it('leaves small numbers alone', () => { + expect(compact(999)).toBe('999'); + expect(compact(12)).toBe('12'); + expect(compact(0.5)).toBe('0.5'); + expect(compact(-7)).toBe('-7'); + }); + + it('abbreviates thousands and up', () => { + expect(compact(1000)).toBe('1K'); + expect(compact(1500)).toBe('1.5K'); + expect(compact(1234567)).toBe('1.2M'); + expect(compact(2300000000)).toBe('2.3B'); + expect(compact(1e12)).toBe('1T'); + expect(compact(10n ** 21n)).toBe('1Sx'); + }); + + it('carries when rounding crosses a scale', () => { + expect(compact(999950)).toBe('1M'); + expect(compact(999999)).toBe('1M'); + expect(compact(999.99)).toBe('1K'); + expect(compact(999.999, { digits: 2 })).toBe('1K'); + expect(compact(-999.99)).toBe('-1K'); + expect(compact(999.99, { long: true })).toBe('1 thousand'); + expect(compact(999.4)).toBe('999.4'); + }); + + it('handles negatives, strings, and decimals', () => { + expect(compact(-1500)).toBe('-1.5K'); + expect(compact('1500.75')).toBe('1.5K'); + }); + + it('honors digits and long options', () => { + expect(compact(1234567, { digits: 2 })).toBe('1.23M'); + expect(compact(1234567, { digits: 0 })).toBe('1M'); + expect(compact(1500000, { long: true })).toBe('1.5 million'); + expect(compact(2000, { long: true })).toBe('2 thousand'); + }); + + it('ignores leading zeros when picking a scale', () => { + expect(compact('0001')).toBe('1'); + expect(compact('0001000')).toBe('1K'); + expect(compact('00')).toBe('0'); + }); + + it('keeps the top scale instead of failing at the upper bound', () => { + expect(compact(10n ** 36n - 1n)).toBe('1000Dc'); + expect(compact(10n ** 36n - 1n, { long: true })).toBe('1000 decillion'); + }); + + it('rejects invalid input', () => { + expect(compact('abc')).toBe(false); + expect(compact(NaN)).toBe(false); + expect(compact(Infinity)).toBe(false); + expect(compact(1e37)).toBe(false); + expect(compact({})).toBe(false); + }); +}); + +describe('fancy', () => { + it('defaults to circled digits', () => { + expect(fancy(42)).toBe('④②'); + expect(fancy(0)).toBe('⓪'); + }); + + it('supports every listed style', () => { + const expected = { + circled: '④②', + superscript: '⁴²', + subscript: '₄₂', + fullwidth: '42', + bold: '𝟒𝟐', + doublestruck: '𝟜𝟚', + sans: '𝟦𝟤', + monospace: '𝟺𝟸', + keycap: '4️⃣2️⃣', + emoji: '4️⃣2️⃣', + clock: '🕓🕑', + braille: '⠼⠙⠃' + }; + expect([...FANCY_STYLE_NAMES].sort()).toEqual(Object.keys(expected).sort()); + for (const [style, out] of Object.entries(expected)) { + expect(fancy(42, style)).toBe(out); + } + }); + + it('maps minus signs and decimal points', () => { + expect(fancy(-3.5, 'fullwidth')).toBe('-3.5'); + expect(fancy(-3.5, 'superscript')).toBe('⁻³˙⁵'); + expect(fancy(-3.5, 'braille')).toBe('⠼⠤⠉⠨⠑'); + expect(fancy('-42', 'circled')).toBe('−④②'); + expect(fancy(814, 'clock')).toBe('🕗🕐🕓'); + expect(fancy(10.5, 'clock')).toBe('🕐🕛·🕔'); + }); + + it('handles BigInt and exponent-form integers', () => { + expect(fancy(10n ** 3n, 'doublestruck')).toBe('𝟙𝟘𝟘𝟘'); + expect(fancy(1e21, 'subscript')).toBe('₁₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀'); + }); + + it('rejects unknown styles and bad input', () => { + expect(fancy(42, 'wingdings')).toBe(false); + expect(fancy('abc')).toBe(false); + expect(fancy(NaN)).toBe(false); + expect(fancy(1.5e-7)).toBe(false); + }); +}); + +describe('egyptian', () => { + it('repeats symbols additively', () => { + expect(egyptian(1)).toBe('𓏺'); + expect(egyptian(9)).toBe('𓏺'.repeat(9)); + expect(egyptian(10)).toBe('𓎆'); + expect(egyptian(42)).toBe('𓎆𓎆𓎆𓎆𓏺𓏺'); + expect(egyptian(1000)).toBe('𓆼'); + expect(egyptian(1000000)).toBe('𓁨'); + expect(egyptian(1234567)).toBe('𓁨𓆐𓆐𓂭𓂭𓂭𓆼𓆼𓆼𓆼𓍢𓍢𓍢𓍢𓍢𓎆𓎆𓎆𓎆𓎆𓎆𓏺𓏺𓏺𓏺𓏺𓏺𓏺'); + }); + + it('rejects zero and values beyond 9,999,999', () => { + expect(egyptian(0)).toBe(false); + expect(egyptian(10000000)).toBe(false); + expect(egyptian(-1)).toBe(false); + expect(egyptian(1.5)).toBe(false); + }); +}); + +describe('babylonian', () => { + it('writes base-60 places with tens and ones wedges', () => { + expect(babylonian(1)).toBe('𒁹'); + expect(babylonian(10)).toBe('𒌋'); + expect(babylonian(42)).toBe('𒌋𒌋𒌋𒌋𒁹𒁹'); + expect(babylonian(59)).toBe('𒌋𒌋𒌋𒌋𒌋𒁹𒁹𒁹𒁹𒁹𒁹𒁹𒁹𒁹'); + expect(babylonian(60)).toBe('𒁹 𒑊'); + expect(babylonian(61)).toBe('𒁹 𒁹'); + expect(babylonian(3600)).toBe('𒁹 𒑊 𒑊'); + expect(babylonian(1984)).toBe('𒌋𒌋𒌋𒁹𒁹𒁹 𒁹𒁹𒁹𒁹'); + }); + + it('uses the placeholder for zero', () => { + expect(babylonian(0)).toBe('𒑊'); + }); + + it('accepts BigInt and rejects invalid input', () => { + expect(babylonian(10n ** 18n)).toMatch(/^𒁹/); + expect(babylonian(-1)).toBe(false); + expect(babylonian('x')).toBe(false); + }); +}); + +describe('greek', () => { + it('uses Ionic letters with keraia', () => { + expect(greek(1)).toBe('αʹ'); + expect(greek(6)).toBe('ϛʹ'); + expect(greek(42)).toBe('μβʹ'); + expect(greek(90)).toBe('ϟʹ'); + expect(greek(900)).toBe('ϡʹ'); + expect(greek(1999)).toBe('͵αϡϟθʹ'); + expect(greek(2026)).toBe('͵βκϛʹ'); + expect(greek(1000)).toBe('͵αʹ'); + }); + + it('rejects zero and values over 9999', () => { + expect(greek(0)).toBe(false); + expect(greek(10000)).toBe(false); + expect(greek(-5)).toBe(false); + }); +}); + +describe('formal Chinese and Japanese numerals', () => { + it('uses 大写 financial forms in Chinese', () => { + expect(chinese(42, { formal: true })).toBe('肆拾贰'); + expect(chinese(10, { formal: true })).toBe('壹拾'); + expect(chinese(1001, { formal: true })).toBe('壹仟零壹'); + expect(chinese(123456, { formal: true })).toBe('壹拾贰万叁仟肆佰伍拾陆'); + expect(chinese(100000001, { formal: true })).toBe('壹亿零壹'); + expect(chinese(10001, { formal: true })).toBe('壹万零壹'); + expect(chinese(10010, { formal: true })).toBe('壹万零壹拾'); + expect(chinese(10100, { formal: true })).toBe('壹万零壹佰'); + expect(chinese(0, { formal: true })).toBe('零'); + }); + + it('uses 大字 forms in Japanese', () => { + expect(japanese(42, { formal: true })).toBe('四拾弐'); + expect(japanese(10, { formal: true })).toBe('壱拾'); + expect(japanese(1000, { formal: true })).toBe('壱千'); + expect(japanese(1001, { formal: true })).toBe('壱千壱'); + expect(japanese(123456, { formal: true })).toBe('壱拾弐万参千四百五拾六'); + expect(japanese(0, { formal: true })).toBe('零'); + expect(japanese(0)).toBe('ゼロ'); + }); + + it('is off by default and reachable through toWords', () => { + expect(chinese(42)).toBe('四十二'); + expect(japanese(1000)).toBe('千'); + expect(toWords(42, { lang: 'zh', formal: true })).toBe('肆拾贰'); + expect(toWords(10000, { lang: 'ja', formal: true })).toBe('壱万'); + }); +}); + +describe('language aliases', () => { + it('accepts bahasa for Indonesian', () => { + expect(numberstring(42, { lang: 'bahasa' })).toBe('empat puluh dua'); + }); +}); + +describe('nato', () => { + it('reads digits with ICAO pronunciations', () => { + expect(nato(1984)).toBe('wun niner ait fower'); + expect(nato(42)).toBe('fower too'); + expect(nato(0)).toBe('zero'); + expect(nato('007')).toBe('zero zero seven'); + expect(nato('000')).toBe('zero zero zero'); + expect(nato('0000')).toBe('zero zero zero zero'); + expect(nato('00100')).toBe('zero zero wun zero zero'); + expect(nato('01200')).toBe('zero wun too zero zero'); + expect(nato(10000)).toBe('wun zero tousand'); + }); + + it('reads whole hundreds and thousands as words', () => { + expect(nato(500)).toBe('fife hundred'); + expect(nato(1000)).toBe('wun tousand'); + expect(nato(2500)).toBe('too tousand fife hundred'); + expect(nato(11000)).toBe('wun wun tousand'); + expect(nato(25000)).toBe('too fife tousand'); + expect(nato(100000)).toBe('wun zero zero zero zero zero'); + }); + + it('handles decimals, negatives, BigInt, and options', () => { + expect(nato(3.14)).toBe('tree decimal wun fower'); + expect(nato('123.45')).toBe('wun too tree decimal fower fife'); + expect(nato(-7)).toBe('minus seven'); + expect(nato(10n ** 3n)).toBe('wun tousand'); + expect(nato(2500, { digits: true })).toBe('too fife zero zero'); + expect(nato(1984, { cap: 'upper' })).toBe('WUN NINER AIT FOWER'); + }); + + it('is also exported as icao and military', () => { + expect(icao).toBe(nato); + expect(military).toBe(nato); + }); + + it('rejects invalid input', () => { + expect(nato('abc')).toBe(false); + expect(nato(NaN)).toBe(false); + expect(nato(Infinity)).toBe(false); + expect(nato(null)).toBe(false); + }); +}); + +describe('morse', () => { + it('encodes digits', () => { + expect(morse(0)).toBe('-----'); + expect(morse(42)).toBe('....- ..---'); + expect(morse('1984')).toBe('.---- ----. ---.. ....-'); + expect(morse(10n ** 3n)).toBe('.---- ----- ----- -----'); + }); + + it('encodes point and minus', () => { + expect(morse(3.1)).toBe('...-- .-.-.- .----'); + expect(morse(-5)).toBe('-....- .....'); + }); + + it('rejects invalid input', () => { + expect(morse('sos')).toBe(false); + expect(morse(NaN)).toBe(false); + expect(morse(Infinity)).toBe(false); + }); +}); + +describe('telephone oh option', () => { + it('says oh for zero', () => { + expect(telephone('555-0100', { oh: true })).toBe('five five five oh one oh oh'); + expect(telephone('555-0100')).toBe('five five five zero one zero zero'); + expect(telephone(8675309, { oh: true, cap: 'title' })).toBe('Eight Six Seven Five Three Oh Nine'); + }); +}); + +describe('scientific', () => { + it('formats with a superscript exponent by default', () => { + expect(scientific(1984)).toBe('1.984 × 10³'); + expect(scientific(42)).toBe('4.2 × 10¹'); + expect(scientific(1)).toBe('1 × 10⁰'); + expect(scientific(0)).toBe('0 × 10⁰'); + expect(scientific(100)).toBe('1 × 10²'); + expect(scientific(0.00042)).toBe('4.2 × 10⁻⁴'); + expect(scientific('0.5')).toBe('5 × 10⁻¹'); + expect(scientific(-1500)).toBe('-1.5 × 10³'); + }); + + it('keeps an exact mantissa for floats, BigInt, and exponent-form input', () => { + expect(scientific(1.5e-7)).toBe('1.5 × 10⁻⁷'); + expect(scientific(-1.5e-7)).toBe('-1.5 × 10⁻⁷'); + expect(scientific(1e21)).toBe('1 × 10²¹'); + expect(scientific(6.02214076e23)).toBe('6.02214076 × 10²³'); + expect(scientific(10n ** 36n - 1n)).toBe('1 × 10³⁶'); + expect(scientific(123456789012345)).toBe('1.23456789012 × 10¹⁴'); + }); + + it('rounds to the requested significant digits with carry', () => { + expect(scientific(1984, { digits: 2 })).toBe('2 × 10³'); + expect(scientific(1984, { digits: 3 })).toBe('1.98 × 10³'); + expect(scientific(999, { digits: 2 })).toBe('1 × 10³'); + }); + + it('supports caret, e, and words formats', () => { + expect(scientific(1984, { format: 'caret' })).toBe('1.984 × 10^3'); + expect(scientific(1984, { format: 'e' })).toBe('1.984e3'); + expect(scientific(0.00042, { format: 'e' })).toBe('4.2e-4'); + expect(scientific(1984, { format: 'words' })).toBe('one point nine eight four times ten to the third'); + expect(scientific(0.00042, { format: 'words' })).toBe('four point two times ten to the negative fourth'); + expect(scientific(1, { format: 'words' })).toBe('one'); + expect(scientific(-0.00042, { format: 'words', cap: 'title' })).toBe('Negative Four Point Two Times Ten To The Negative Fourth'); + }); + + it('rejects invalid input and formats', () => { + expect(scientific('abc')).toBe(false); + expect(scientific(NaN)).toBe(false); + expect(scientific(Infinity)).toBe(false); + expect(scientific(1, { format: 'latex' })).toBe(false); + }); +}); + +describe('radix, binary, octal, hex', () => { + it('converts integers between bases', () => { + expect(binary(42)).toBe('101010'); + expect(octal(42)).toBe('52'); + expect(hex(42)).toBe('2a'); + expect(radix(42, 36)).toBe('16'); + expect(hex('255')).toBe('ff'); + expect(binary(10n ** 20n)).toBe((10n ** 20n).toString(2)); + }); + + it('supports prefix, upper, pad, and negatives', () => { + expect(hex(255, { prefix: true, upper: true })).toBe('0xFF'); + expect(binary(-5, { prefix: true })).toBe('-0b101'); + expect(binary(5, { pad: 8 })).toBe('00000101'); + expect(octal(8, { prefix: true })).toBe('0o10'); + expect(radix(42, 36, { prefix: true })).toBe('16'); + }); + + it('rejects bad bases and non-integers', () => { + expect(radix(42, 1)).toBe(false); + expect(radix(42, 37)).toBe(false); + expect(radix(1.5)).toBe(false); + expect(radix('x')).toBe(false); + expect(binary(NaN)).toBe(false); + }); +}); + +describe('bytes', () => { + it('uses decimal units by default', () => { + expect(bytes(0)).toBe('0 B'); + expect(bytes(999)).toBe('999 B'); + expect(bytes(1000)).toBe('1 KB'); + expect(bytes(1536)).toBe('1.5 KB'); + expect(bytes(1048576)).toBe('1 MB'); + expect(bytes(1536000)).toBe('1.5 MB'); + expect(bytes(10n ** 15n)).toBe('1 PB'); + expect(bytes(999999)).toBe('1 MB'); + }); + + it('uses binary units on request', () => { + expect(bytes(1536, { binary: true })).toBe('1.5 KiB'); + expect(bytes(1048576, { binary: true })).toBe('1 MiB'); + expect(bytes(1023, { binary: true })).toBe('1023 B'); + }); + + it('spells out long form with plurals', () => { + expect(bytes(1536, { long: true })).toBe('one point five kilobytes'); + expect(bytes(1, { long: true })).toBe('one byte'); + expect(bytes(1000, { long: true })).toBe('one kilobyte'); + expect(bytes(1048576, { binary: true, long: true })).toBe('one mebibyte'); + }); + + it('honors digits and rejects invalid input', () => { + expect(bytes(1536, { digits: 0 })).toBe('2 KB'); + expect(bytes(1234567890, { digits: 6 })).toBe('1.234568 GB'); + expect(bytes(1234567890, { digits: 3 })).toBe('1.235 GB'); + expect(bytes(100000000000123456000000000000000000n, { digits: 6 })).toBe('100000000000.123456 YB'); + expect(bytes(10n ** 30n)).toBe('1000000 YB'); + expect(bytes(1999, { digits: 0 })).toBe('2 KB'); + expect(bytes(999999999, { digits: 2 })).toBe('1 GB'); + expect(bytes(1536, { digits: 1.5 })).toBe('1.5 KB'); + expect(bytes(1536, { digits: NaN })).toBe('1.5 KB'); + expect(bits(1536, { digits: Infinity })).toBe('1.5 kb'); + expect(compact(1536, { digits: 2.7 })).toBe('1.54K'); + expect(scientific(1984, { digits: 2.9 })).toBe('2 × 10³'); + expect(bytes(-1)).toBe(false); + expect(bytes(1.5)).toBe(false); + expect(bytes('abc')).toBe(false); + }); +}); + +describe('bits', () => { + it('uses bandwidth-style units', () => { + expect(bits(0)).toBe('0 b'); + expect(bits(999)).toBe('999 b'); + expect(bits(1000)).toBe('1 kb'); + expect(bits(1500000)).toBe('1.5 Mb'); + expect(bits(10n ** 9n)).toBe('1 Gb'); + }); + + it('supports binary units and long form', () => { + expect(bits(1536, { binary: true })).toBe('1.5 Kib'); + expect(bits(1500000, { long: true })).toBe('one point five megabits'); + expect(bits(1, { long: true })).toBe('one bit'); + expect(bits(1048576, { binary: true, long: true })).toBe('one mebibit'); + }); + + it('rejects invalid input', () => { + expect(bits(-1)).toBe(false); + expect(bits(2.5)).toBe(false); + }); +}); + +describe('clock', () => { + it('maps hours to clock faces', () => { + expect(clock(1)).toBe('🕐'); + expect(clock(3)).toBe('🕒'); + expect(clock(12)).toBe('🕛'); + expect(clock(0)).toBe('🕛'); + expect(clock(24)).toBe('🕛'); + expect(clock(15)).toBe('🕒'); + }); + + it('rounds H:MM to the nearest half hour', () => { + expect(clock('3:30')).toBe('🕞'); + expect(clock('3:14')).toBe('🕒'); + expect(clock('3:15')).toBe('🕞'); + expect(clock('12:44')).toBe('🕧'); + expect(clock('12:45')).toBe('🕐'); + expect(clock('23:50')).toBe('🕛'); + expect(clock('0:30')).toBe('🕧'); + }); + + it('rejects invalid input', () => { + expect(clock(25)).toBe(false); + expect(clock(1.5)).toBe(false); + expect(clock('x')).toBe(false); + expect(clock('3:60')).toBe(false); + expect(clock(null)).toBe(false); + }); +}); + +describe('cap casing styles', () => { + it('joins words for code-style casing', () => { + expect(numberstring(123, { cap: 'camel' })).toBe('oneHundredTwentyThree'); + expect(numberstring(123, { cap: 'pascal' })).toBe('OneHundredTwentyThree'); + expect(numberstring(123, { cap: 'snake' })).toBe('one_hundred_twenty_three'); + expect(numberstring(123, { cap: 'kebab' })).toBe('one-hundred-twenty-three'); + expect(numberstring(123, { cap: 'hyphen' })).toBe('one-hundred-twenty-three'); + expect(numberstring(123, { cap: 'constant' })).toBe('ONE_HUNDRED_TWENTY_THREE'); + expect(numberstring(123, { cap: 'screaming' })).toBe('ONE_HUNDRED_TWENTY_THREE'); + expect(numberstring(123, { cap: 'dot' })).toBe('one.hundred.twenty.three'); + }); + + it('sentence case capitalizes only the first letter', () => { + expect(numberstring(123, { cap: 'sentence' })).toBe('One hundred twenty-three'); + expect(numberstring(-5, { cap: 'sentence' })).toBe('Negative five'); + }); + + it('works through delegated paths and other helpers', () => { + expect(numberstring(-3.5, { cap: 'snake' })).toBe('negative_three_point_five'); + expect(numberstring(-123, { cap: 'camel' })).toBe('negativeOneHundredTwentyThree'); + expect(numberstring(-123, { cap: 'pascal' })).toBe('NegativeOneHundredTwentyThree'); + expect(numberstring(-5n, { cap: 'title', punc: '!' })).toBe('Negative Five!'); + expect(numberstring('42', { cap: 'camel', punc: '!' })).toBe('fortyTwo!'); + expect(numberstring(42, { lang: 'es', cap: 'kebab' })).toBe('cuarenta-y-dos'); + expect(numberstring(1001, { and: true, cap: 'constant' })).toBe('ONE_THOUSAND_AND_ONE'); + expect(ordinal(21, { cap: 'camel' })).toBe('twentyFirst'); + expect(decimal(3.14, { cap: 'pascal' })).toBe('ThreePointOneFour'); + expect(currency('$1.50', { cap: 'snake' })).toBe('one_dollar_and_fifty_cents'); + expect(nato(1984, { cap: 'kebab' })).toBe('wun-niner-ait-fower'); + }); + + it('leaves unknown styles alone and exports the list', () => { + expect(numberstring(42, { cap: 'wingdings' })).toBe('forty-two'); + expect(CAP_STYLES).toContain('camel'); + expect(CAP_STYLES).toContain('snake'); + expect(CAP_STYLES).toContain('hyphen'); + expect(CAP_STYLES).toContain('screaming'); + expect(roman(4, { lower: true })).toBe('iv'); + }); +}); + +describe('year beyond 9999', () => { + it('reads far-future years as cardinals', () => { + expect(year(10000)).toBe('ten thousand'); + expect(year(8675309)).toBe('eight million six hundred seventy-five thousand three hundred nine'); + expect(year(10n ** 6n)).toBe('one million'); + expect(year(1984n)).toBe('nineteen eighty-four'); + expect(year(12345, { cap: 'title' })).toBe('Twelve Thousand Three Hundred Forty-Five'); + }); + + it('still rejects negatives and non-integers', () => { + expect(year(-1)).toBe(false); + expect(year(-1n)).toBe(false); + expect(year(1.5)).toBe(false); + }); +}); diff --git a/test/fuzz.test.js b/test/fuzz.test.js new file mode 100644 index 0000000..cc3d145 --- /dev/null +++ b/test/fuzz.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import * as lib from '../index.js'; + +/** + * Every public function must return a string, number, bigint, or false for + * any input and any options object. Nothing may throw. This is the contract + * the README promises, and the one that lets callers skip try/catch. + */ + +const INPUTS = [ + 0, -0, 1, -1, 0.1, -0.5, 1e21, -1e21, 1e-7, NaN, Infinity, -Infinity, + Number.MAX_SAFE_INTEGER, Number.MAX_VALUE, Number.MIN_VALUE, + 0n, -1n, 10n ** 36n, 10n ** 37n, -(10n ** 36n), + '', '0', '-0', '00', '1e5', 'abc', '٤٢', '42abc', ' 42 ', '1,000', '1.2.3', '.5', '5.', '-', '--5', '🕒', + null, undefined, true, false, {}, [], [42], () => 1, Symbol('x') +]; + +const OPTIONS = [ + undefined, null, {}, { cap: 'camel' }, { cap: 'nope' }, { cap: 42 }, { lang: 'es' }, { lang: 'xx' }, { lang: 42 }, + { and: true }, { formal: true }, { punc: '!' }, { punc: 42 }, { point: 'dot' }, { digits: 99 }, { digits: -1 }, + { format: 'words' }, { format: 'zzz' }, { binary: true, long: true }, { prefix: true, pad: 100 }, { lower: true }, + { vertical: true }, { oh: true } +]; + +const RESULT_TYPES = new Set(['string', 'number', 'bigint', 'boolean']); + +describe('fuzz: no public function throws', () => { + const fns = Object.entries(lib).filter(([, v]) => typeof v === 'function'); + + it.each(fns.map(([name]) => name))('%s', (name) => { + const fn = lib[name]; + for (const input of INPUTS) { + for (const opt of OPTIONS) { + let result; + expect(() => { result = fn(input, opt); }).not.toThrow(); + expect(RESULT_TYPES.has(typeof result)).toBe(true); + if (typeof result === 'boolean') expect(result).toBe(false); + } + } + }); + + it('fraction and radix also survive hostile second arguments', () => { + for (const a of INPUTS) { + for (const b of INPUTS) { + expect(() => lib.fraction(a, b)).not.toThrow(); + expect(() => lib.radix(a, b)).not.toThrow(); + } + } + }); +}); diff --git a/test/index.test.js b/test/index.test.js index 2c21015..e1bb6e8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -594,8 +594,30 @@ describe('roman', () => { expect(roman(-1)).toBe(false); }); - it('returns false for numbers over 3999', () => { - expect(roman(4000)).toBe(false); + it('uses vinculum notation above 3999', () => { + const bar = '\u0305'; + const dbl = '\u033F'; + expect(roman(4000)).toBe(`I${bar}V${bar}`); + expect(roman(4001)).toBe(`I${bar}V${bar}I`); + expect(roman(1000000)).toBe(`M${bar}`); + expect(roman(3999999)).toBe(`M${bar}M${bar}M${bar}C${bar}M${bar}X${bar}C${bar}I${bar}X${bar}CMXCIX`); + expect(roman(4000000)).toBe(`I${dbl}V${dbl}`); + expect(roman(8675309)).toBe(`V${dbl}I${dbl}I${dbl}I${dbl}D${bar}C${bar}L${bar}X${bar}X${bar}V${bar}CCCIX`); + expect(roman(1000000000)).toBe(`M${dbl}`); + expect(roman(3999999999)).toMatch(/^M\u033FM\u033FM\u033F/); + }); + + it('skips empty middle groups', () => { + expect(roman(1000001)).toBe('M\u0305I'); + expect(roman(2000000000)).toBe('M\u033FM\u033F'); + }); + + it('lowercases barred numerals', () => { + expect(roman(4000, { lower: true })).toBe('i\u0305v\u0305'); + }); + + it('returns false above 3,999,999,999', () => { + expect(roman(4000000000)).toBe(false); }); it('returns false for non-integers', () => { @@ -775,6 +797,10 @@ describe('chinese', () => { it('handles zeros in the middle', () => { expect(chinese(101)).toBe('一百零一'); expect(chinese(1001)).toBe('一千零一'); + expect(chinese(10001)).toBe('一万零一'); + expect(chinese(10010)).toBe('一万零一十'); + expect(chinese(100000001)).toBe('一亿零一'); + expect(chinese(100010000)).toBe('一亿零一万'); }); it('converts thousands and wan', () => { @@ -976,7 +1002,7 @@ describe('year', () => { it('returns false for invalid years', () => { expect(year(-1)).toBe(false); - expect(year(10000)).toBe(false); + expect(year(10000)).toBe('ten thousand'); expect(year(3.14)).toBe(false); }); });