npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

moneyspeak

v0.0.2

Published

Verbalize currency amounts for speech synthesizers. Derive the spoken form from Intl; patch only the gaps.

Readme

moneyspeak

Turn a currency amount into a string a speech synthesizer reads correctly.

Engines read numbers fine and currency codes badly: USD123.45 becomes "US dollars" on some, SGD123.45 is spelled out on others. verbalizeMoney returns the words and leaves the digits to the engine.

import { verbalizeMoney } from "moneyspeak";

verbalizeMoney({ amount: 123.45, currency: "SGD", locale: "en-US" });
// {
//   spoken: "123 Singapore dollars and 45 cents",
//   display: "SGD 123.45",
//   strategy: "major-minor",
//   warnings: []
// }

API

verbalizeMoney(input, options?) => Result

| input | | | --- | --- | | amount | anything whose toString yields a plain decimal: a string, number, bigint, or a decimal library value. A string is exact. | | currency | ISO 4217 code | | locale | BCP 47 tag, defaults to the runtime locale |

| options | | | --- | --- | | style | "auto" \| "major-minor" \| "decimal-name" \| "major-only" | | name | replace the spoken currency name | | subunit | replace the minor unit name, or false to drop it | | connector | replace the word joining major and minor | | decimalBreak | "auto" inserts a word joiner before an ASCII . for non-Latin scripts; default "none" | | locale | override input.locale |

Returns { spoken, display, strategy, warnings }.

Also exported:

  • resolveCurrency(currency, locale) — the derived name, symbol, exponent and subunit; sources records where the values that can come from data (name, exponent, subunit) came from.
  • parseMoney(text, locale, { currency }) — best-effort read of a formatted amount.

DOM helpers, in moneyspeak/dom:

  • setAccessibleMoney(el, input, options?) — writes a visually hidden spoken form and the conventional display form. The hidden node is a <div> carrying lang (and dir="auto"), because VoiceOver honors lang on a block element and ignores it on a <span> even with display:block.
  • defineCurrencyAmount() — registers <currency-amount amount currency locale>.
  • srOnlyCss — the stylesheet for the hidden node. Add it to the page once, or the spoken form is visible:
    const style = document.createElement("style");
    style.textContent = srOnlyCss;
    document.head.append(style);

Strategy

| Condition | strategy | Output | | --- | --- | --- | | exponent > 0 and a subunit resolves for the locale | major-minor | 123 Singapore dollars and 45 cents | | exponent > 0 and no subunit resolves | decimal-name | 123.45 Singapore dollars | | fraction is zero, or exponent is 0 | major-only | 123 Japanese yen |

A subunit resolves from the language's word for the currency's kind, or, when the language has no such word, from the kind's international name. The decimal reading is left for a currency whose kind is unknown.

spoken never contains a currency symbol, and never a bare ISO code except for a code with no CLDR name at all, which warns and falls back to the code.

Data

Everything derivable comes from Intl at runtime: the name (singular and plural), symbol, digits and exponent.

Three files hold the resolution data:

  • data/subunit-kinds.json — the subunit kind for each currency (USD -> cent, EUR -> eurocent, GBP -> penny), and the international name for each kind. null marks a currency with no subunit. The kind is the concept, so it is not repeated per currency.
  • data/subunits.json — the word for each kind per language, e.g. en -> cent -> { one: "cent", other: "cents" }. One entry serves every currency that shares the kind, so adding a language means adding words, not re-entering "cent" for each currency.
  • data/overrides.json — exponent corrections, spoken name overrides, per-locale connector and split settings, and per-locale or per-language subunit exceptions (a word that differs from the kind's default, e.g. zh -> USD -> 美分).

A subunit is resolved in four tiers, most specific first:

  1. an explicit override for the locale, then for the language; null there means no subunit.
  2. the currency's kind, from subunit-kinds.json. A currency absent from the file is guessed only when its exponent is 0, which means no subunit; exponents 2 and 3 are not guessed, because they are not a cent or a fils in general (CZK is haléř, PLN is grosz).
  3. the language's word for that kind, from subunits.json.
  4. the kind's international name, so a known kind never reaches the decimal reading only because a language lacks a word.

The decimal reading is left for a currency whose kind is unknown.

data/example-matrix.json holds the locale and currency surface used by the docs demo, the matrix test and matrix:dump, so the three cannot drift apart. It is an example set, not a support boundary: any BCP 47 locale and any ISO 4217 code is accepted.

pnpm compare:data checks the subunit plural categories against CLDR and the exponents against ISO 4217, reports the model coverage, and names the currencies whose exponent deliberately differs from ISO. Per-entry sources are in data/SOURCES.md.

Limits

  • Output follows the runtime's CLDR, so it can differ between browsers and Node versions.
  • The fraction digits come from CLDR by default. data/overrides.json corrects the cases where CLDR's display digits are not the ISO 4217 minor unit (IDR), and compare:data lists the currencies where the two disagree on purpose (LAK, MMK, whose subunits are out of use).
  • One minor unit per currency. Intermediate units (jiao, dime) are not modelled, because no synthesizer verbalizes them.
  • When a language has no word for a kind, the reading uses the kind's international name, which can put a non-native word inside another language: Korean with a Swedish krona reads 1 스웨덴 크로나 5 øre, and Hindi with a Swiss franc reads 1 स्विस फ़्रैंक 5 centimes. It keeps the reading major-plus-minor instead of dropping to the decimal, and data/subunits.json is where a language's own word replaces it.
  • The integer digits are left to the engine. The decimal separator is not. VoiceOver parsed the number itself and read an ASCII . as an English "point" even with a Chinese or Japanese voice, and lang only selects the voice. Measured: macOS 26.5.2 still does, iOS 27 no longer does. An opt-in workaround, { decimalBreak: "auto" }, inserts an invisible word joiner (U+2060) before an ASCII . for non-Latin scripts, which stops the parsing so the synthesizer normalizes the number itself; measured on macOS 26.5.2 it turns 123.45人民币 from an English "point" into 点四五. It is off by default, because it is harmful elsewhere: on Google TTS it drops the fractional part (123<wj>.45人民币 reads 一百二十三人民币), and TalkBack reads U+2060 aloud as "word joiner".
  • Amounts beyond Number.MAX_SAFE_INTEGER keep their digits in spoken and display, but the plural of the currency name is chosen through Intl.PluralRules, which coerces to a Number: a very large amount in a language with rich plurals (Russian, Polish, Arabic) can therefore take the wrong form. Pass a value within safe-integer range if that matters.
  • Verified on VoiceOver (macOS 26.5.2), in item navigation over block elements carrying lang: the major-plus-minor and major-only renders read correctly for en-US, ja-JP, th-TH, ko-KR, hi-IN, id-ID, de-DE and fr-FR. Read-all (VO+A) ignores lang, so it is not a valid check for a multi-language page.
  • Measured on Google TTS (com.google.android.tts, OnePlus 6T, Android 15): the fifteen locale renders all read the minor unit with no "point". On that build the bare codes USD123.45, SGD123.45 and CNY123.45 are named correctly too, so the library's value there is consistency, not a rescue. The engines that spell a code out are the ones the corpus documented.

Develop

pnpm install
pnpm test          # vitest, includes the full locale x currency matrix
pnpm build         # tsdown, ESM and CJS
pnpm typecheck     # tsc --noEmit
pnpm docs          # Vite docs app
pnpm smoke         # pack, install the tarball, load it as a consumer would
pnpm compare:data  # data vs CLDR and ISO 4217, exits non-zero on a gap
pnpm matrix:dump   # full matrix for review when the runtime or data changes

MIT