Thank you for taking the time to contribute! This document covers everything you need — from filing a bug report to shipping a new locale.
- Code of Conduct
- Getting Started
- Development Setup
- Project Structure
- How to Contribute
- Adding a New Locale
- Coding Guidelines
- Commit Message Format
- Running Tests
- Building
- Documentation
- Release Process
This project follows the Contributor Covenant Code of Conduct. By participating you agree to abide by its terms.
- Fork the repository on GitHub.
- Clone your fork locally:
git clone https://github.com/<your-username>/to-words.git cd to-words
- Install dependencies (requires Node ≥ 20):
npm install
- Create a branch for your change:
git checkout -b feat/my-feature # or git checkout -b fix/issue-123
| Command | Purpose |
|---|---|
npm test |
Run the full test suite in watch mode |
npm run test -- run |
Run tests once (CI mode) |
npm run test -- run --coverage |
Run tests with coverage report |
npm run lint |
Check for linting errors |
npm run lint:fix |
Auto-fix linting errors |
npm run build |
Full production build (tsc CJS/ESM + Rolldown UMD + CLI) |
npm run test:runtime |
Smoke-test compiled ESM, CJS, locale, and CLI entry points |
npm run docs:dev |
Run the VitePress documentation locally |
npm run docs:build |
Build and verify the deployable documentation site |
npm run commit |
Interactive commit with Commitizen |
Tip: Run
npm run lint:fixbefore committing — the pre-commit hook will block if there are lint errors.
src/
ToWordsCore.ts # Core conversion engine (no bundled locales)
ToWords.ts # Full-bundle class + functional exports (toWords, toOrdinal, toCurrency)
# + locale auto-detection (detectLocale, setLocaleDetector)
types.ts # Shared TypeScript types
cli.ts # CLI entry point
locales/
index.ts # LOCALES registry (maps locale code → class)
en-US.ts # Example locale
en-IN.ts
… # 135 locale files total
__tests__/
ToWords.test.ts # Full-bundle + functional helper tests
ToWordsCore.test.ts # Core engine tests
<locale>.test.ts # Per-locale tests (one file per locale)
scripts/
build-umd.ts # UMD bundle script
check-docs-build.mjs # Verifies VitePress output, base paths, links, and demo SSR
verify-docs-deployment.mjs # Verifies the deployed Pages site and client bundle
runtime-smoke.mjs # Compiled ESM/CJS/locale/CLI release smoke test
Before opening a bug report, please search existing issues to avoid duplicates.
When filing a new issue, include:
- Package version (
npm list to-words) - Node.js version (
node -v) - Locale code you are using
- Minimal reproduction — a small code snippet that shows the wrong output
- Expected vs actual output
Open an issue with the label enhancement and describe:
- The use case you are trying to solve
- The proposed API or behaviour change
- Any locales or edge cases that would be affected
- Make sure
npm test -- runandnpm run lintboth pass locally. - Keep PRs focused — one feature or fix per PR.
- If you are adding a feature, add tests that cover it.
- Fill in the PR template completely.
- Link related issues in the PR description (
Closes #123).
This is the most common contribution. Follow these steps:
Add src/locales/<locale-code>.ts. Use an existing locale as a template (e.g. src/locales/en-US.ts).
Your file must:
-
Export a
defaultclass that implementsLocaleInterfacefromsrc/types.ts. -
Export the three locale-level functional helpers at the bottom — they wrap the class with no extra arguments, so callers import them tree-shaken without needing a
localeCode:import { type ConverterOptions, type NumberInput, type OrdinalOptions } from '../types.js'; import { ToWordsCore } from '../ToWordsCoreBase.js'; export default class Locale implements LocaleInterface { public config: LocaleConfig = { // … your locale config … }; } export class ToWords extends ToWordsCore { constructor(options: ToWordsOptions = {}) { super(options); this.setLocale(Locale, '<locale-code>'); } } let instance: ToWords | undefined;
In practice, copy the bottom block verbatim from any existing locale file — the pattern is standardised across all 135 locales. The full registry validates locale configuration automatically and all entry points freeze it on first initialization, so define it completely and deterministically up front rather than mutating it after conversion starts. Standalone locale bundles use the prevalidated base to avoid shipping authoring-time validation code to every browser.
numberWordsMappingmust contain zero, use unique numeric thresholds, and be strictly descending because the core uses binary search. The cross-locale invariant test enforces these requirements.Strict cardinal, ordinal, and currency ceilings are derived from the configured scale structure. If reviewed native-language fixtures justify different ceilings, declare them with
maximumSupportedValuesand add boundary tests for every overridden form. Do not raise a ceiling merely becauserangeMode: 'compose'can mechanically produce a string.
Open src/locales/index.ts and:
- Import your class.
- Add it to the
LOCALESmap with the correct BCP 47 locale code (e.g.'sw-TZ'). - If this is a new language subtag, add its reviewed display name, exact IANA registry description, and documentation page to
scripts/locale-language-identities.ts.
Locale identifiers are checked against the committed IANA Language Subtag Registry. The quality gate rejects invalid or non-canonical language, script, and region subtags, and also verifies that the registered language description matches the human-reviewed implementation identity. This second check catches valid-but-wrong codes, such as identifying an Estonian implementation with the Ewe language code.
Maintainers can refresh the committed registry snapshot after IANA publishes an update:
registry_file="$(mktemp)"
curl --fail --show-error --silent --location --proto '=https' --tlsv1.2 \
https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry \
--output "$registry_file"
npm run locale-registry:update -- "$registry_file"
git diff -- scripts/data/iana-language-subtags.json
rm "$registry_file"Downloading and importing are deliberately separate trust steps. The importer accepts only a local file, validates its size, structure, canonical subtag shapes, expected identities, and registry date, and refuses to roll the snapshot back. Review the generated diff before committing it.
Create __tests__/<locale-code>.test.ts. Copy an existing test file and adjust the expected values. Your test file must contain:
-
Integers (0, positive, negative)
-
Decimals
-
Currency amounts (
{ currency: true }) -
Ordinals (if the locale supports them)
-
A
describe('Functional helpers (locale-level)')block with tests for the three exported helpers — required for 100% coverage:import { toWords as localeToWords, toOrdinal as localeToOrdinal, toCurrency as localeToCurrency, } from '../src/locales/<locale-code>'; describe('Functional helpers (locale-level)', () => { test('toWords', () => { expect(localeToWords(5)).toBeDefined(); }); test('toOrdinal', () => { expect(localeToOrdinal(1)).toBeDefined(); }); test('toCurrency', () => { expect(localeToCurrency(10)).toBeDefined(); }); });
Add a row for your locale in the Supported Locales table in README.md.
npm run lint
npm run test:locale-quality
npm test -- --run
npm run build
npm run test:runtime
npm run test:packageAll tests must pass and coverage must remain at 100% for the files you touched.
- TypeScript — all source files are TypeScript. Avoid
any; useunknownand type guards instead. - No runtime dependencies — the package has zero production
dependencies. Do not add any. - ESM-first — source is native ESM. Import paths must include the
.jsextension (TypeScript resolves these to.tsduring compilation). - No global state in locale files — each locale class is stateless.
- Performance — the conversion hot path is called thousands of times in invoicing apps. Do not add per-call allocations (e.g.
Array.from,Object.keys) insideconvert()without benchmarking first (npm run bench). - Linting —
npm run lintmust pass with zero warnings. The project uses Oxlint with@mastermunj/oxc-config.
This project uses Conventional Commits enforced by commitlint. The easiest way to commit is:
npm run commitThis launches an interactive prompt via Commitizen. Manual commit messages must follow the pattern:
<type>(<scope>): <short description>
[optional body]
[optional footer(s)]
Types: build, ci, chore, docs, feat, fix, perf, refactor, revert, style, test
Examples:
feat(locales): add sw-TZ (Swahili Tanzania) locale
fix(en-IN): correct ordinal for 11th
docs: update README bundle size figures
test(fr-FR): add decimal currency edge cases
init: bootstrap repository metadata
# Watch mode (default)
npm test
# Single run
npm test -- run
# Single run with coverage
npm test -- run --coverage
# Single file
npm test -- run __tests__/en-US.test.ts
# Benchmarks
npm run benchCoverage is measured by Vitest v8. The project targets 100% coverage across all src/** files. PRs that drop coverage below 100% for touched files will not be merged.
npm run buildThis runs (in order): clean → CJS build → ESM build → UMD build → package.json injection → CLI chmod.
The UMD bundles in dist/umd/ are generated per-locale via Rolldown — one bundle for the full package (to-words.min.js) and one per locale (en-US.min.js, etc.).
After building, run npm run test:runtime to verify the compiled ESM, CommonJS, per-locale, and CLI entry points rather than only the TypeScript source used by unit tests.
The documentation site is built with VitePress and deployed by .github/workflows/docs.yml.
# Local development with hot reload
npm run docs:dev
# Production build plus generated-data, base-path, link, asset, and demo checks
npm run docs:build
# Preview the already-built site
npm run docs:previewThe interactive demo imports the same per-locale source entry points that are published to npm. Its locale list is checked against the runtime registry, and locale implementations are loaded on demand so documentation pages do not download the complete locale bundle.
Repository administrators must keep Settings → Pages → Build and deployment → Source set to GitHub Actions. Do not select main/docs: that path publishes the VitePress Markdown sources through Jekyll instead of publishing the generated site. The deployment workflow runs for every push to main, verifies the built artifact before upload, and checks the public page after deployment.
Releases are automated with Release Please and npm trusted publishing:
- Merge Conventional Commits into
main. Mark incompatible changes with!and aBREAKING CHANGE:footer. - Release Please opens or updates
chore(release): <version>. It owns the version changes inpackage.json,package-lock.json,.release-please-manifest.json, andCHANGELOG.md; do not edit those versions manually. - Review and merge the release PR. Release Please creates the GitHub release and
v<version>tag. - The tag starts
publish.yml, which installs cleanly, lints, tests, builds, smoke-tests compiled entry points, verifies package contents, and publishes through npm OIDC with automatic provenance. - Every push to
main, including the release merge, rebuilds and verifies the VitePress site before deploying it to GitHub Pages.
Before a release, verify that the RELEASE_PLEASE_TOKEN repository secret is active and that npm trusted publishing is configured for the mastermunj/to-words repository and publish.yml workflow. Do not create the tag or run npm publish manually.
- Bug or feature? Open an issue
- General question? Start a discussion
- Full API docs: README.md
- Migration from another package: MIGRATION.md