Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,37 @@ Key constants:

## When Making Changes

1. Ensure all tests pass: `npm test`
1. **ALWAYS run lint and tests before committing**: `npm run lint && npm test`
2. Maintain 100% coverage: `npm run test:coverage`
3. Run linter: `npm run lint`
4. Update CHANGELOG.md for any user-facing changes
5. Preserve the fun flair (ASCII art header, tagline)
3. Update CHANGELOG.md for any user-facing changes
4. Preserve the fun flair (ASCII art header, tagline)

## Known Limitations
## PR Review Workflow

- Maximum supported number: `Number.MAX_SAFE_INTEGER` (9,007,199,254,740,991)
- No decimal support
- English only
- No ordinal support (1st, 2nd, 3rd)
- Always check GitHub PR comments before continuing work
- Review feedback from Codex, human reviewers, and CI systems
- Fix valid issues before pushing new commits
- Use `gh pr view <number> --comments` to fetch PR comments

## Supported Languages

- English (default)
- Spanish (`es`, `spanish`, `español`)
- French (`fr`, `french`, `français`)
- German (`de`, `german`, `deutsch`)
- Danish (`da`, `danish`, `dansk`)
- Chinese (`zh`, `chinese`, `中文`)
- Hindi (`hi`, `hindi`, `हिन्दी`)
- Russian (`ru`, `russian`, `русский`)
- Portuguese (`pt`, `portuguese`, `português`)

## 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
53 changes: 45 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100
## Why numberstring?

- **Zero dependencies** - Lightweight and fast
- **8 languages** - English, Spanish, French, German, Danish, Chinese, Hindi, Russian
- **9 languages** - English, Spanish, French, German, Danish, Chinese, Hindi, Russian, Portuguese
- **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
Expand Down Expand Up @@ -191,10 +191,10 @@ comma(1234567); // '1,234,567'

## Multi-Language Support

numberstring supports 8 languages! Each language is in a separate file for easy tree-shaking.
numberstring supports 9 languages! Each language is in a separate file for easy tree-shaking.

```javascript
import { toWords, spanish, french, german, danish, chinese, hindi, russian } from 'numberstring';
import { toWords, spanish, french, german, danish, chinese, hindi, russian, portuguese } from 'numberstring';

// Using toWords with lang option
toWords(42, { lang: 'es' }); // 'cuarenta y dos'
Expand All @@ -204,13 +204,15 @@ toWords(42, { lang: 'da' }); // 'toogfyrre'
toWords(42, { lang: 'zh' }); // '四十二'
toWords(42, { lang: 'hi' }); // 'बयालीस'
toWords(42, { lang: 'ru' }); // 'сорок два'
toWords(42, { lang: 'pt' }); // 'quarenta e dois'

// Or use language functions directly
spanish(1000); // 'mil'
french(80); // 'quatre-vingts'
german(21); // 'einundzwanzig'
chinese(10000); // '一万'
russian(2000); // 'две тысячи'
spanish(1000); // 'mil'
french(80); // 'quatre-vingts'
german(21); // 'einundzwanzig'
chinese(10000); // '一万'
russian(2000); // 'две тысячи'
portuguese(100); // 'cem'
```

### Adding a New Language
Expand Down Expand Up @@ -249,6 +251,41 @@ Languages are modular! To add a new language:
| **Nonillions** | 10^30 | `five nonillion` *(BigInt)* |
| **Decillions** | 10^33 | `five decillion` *(BigInt)* |

## REST API Server

numberstring includes a ready-to-use REST API server!

```bash
cd server
npm install
npm start
# Server running at http://localhost:3456
```

### 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"}
```

## Development

```bash
Expand Down
7 changes: 5 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
*/

// Import language functions
import { english, spanish, french, german, danish, chinese, hindi, russian, LANGUAGES } from './languages/index.js';
import { english, spanish, french, german, danish, chinese, hindi, russian, portuguese, LANGUAGES } from './languages/index.js';

// Re-export language functions
export { spanish, french, german, danish, chinese, hindi, russian };
export { spanish, french, german, danish, chinese, hindi, russian, portuguese };

// ============================================================================
// CONSTANTS
Expand Down Expand Up @@ -630,6 +630,9 @@ const toWords = (n, opt) => {
case 'russian':
result = russian(n);
break;
case 'portuguese':
result = portuguese(n);
break;
default:
result = english(n);
}
Expand Down
7 changes: 6 additions & 1 deletion languages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import danish from './da.js';
import chinese from './zh.js';
import hindi from './hi.js';
import russian from './ru.js';
import portuguese from './pt.js';

/** Supported language codes and aliases */
const LANGUAGES = Object.freeze({
Expand All @@ -45,7 +46,10 @@ const LANGUAGES = Object.freeze({
'हिन्दी': 'hindi',
ru: 'russian',
russian: 'russian',
'русский': 'russian'
'русский': 'russian',
pt: 'portuguese',
portuguese: 'portuguese',
português: 'portuguese'
});

export {
Expand All @@ -57,5 +61,6 @@ export {
chinese,
hindi,
russian,
portuguese,
LANGUAGES
};
145 changes: 145 additions & 0 deletions languages/pt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Portuguese number-to-words converter
* @module languages/pt
*/

const PT_ONES = Object.freeze(['', 'um', 'dois', 'três', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove']);
const PT_TENS = Object.freeze(['', '', 'vinte', 'trinta', 'quarenta', 'cinquenta', 'sessenta', 'setenta', 'oitenta', 'noventa']);
const PT_TEENS = Object.freeze(['dez', 'onze', 'doze', 'treze', 'catorze', 'quinze', 'dezesseis', 'dezessete', 'dezoito', 'dezenove']);
const PT_HUNDREDS = Object.freeze(['', 'cento', 'duzentos', 'trezentos', 'quatrocentos', 'quinhentos', 'seiscentos', 'setecentos', 'oitocentos', 'novecentos']);
const PT_ILLIONS = Object.freeze(['', 'mil', 'milhão', 'bilhão', 'trilhão', 'quatrilhão', 'quintilhão', 'sextilhão', 'septilhão', 'octilhão', 'nonilhão', 'decilhão']);
const PT_ILLIONS_PLURAL = Object.freeze(['', 'mil', 'milhões', 'bilhões', 'trilhões', 'quatrilhões', 'quintilhões', 'sextilhões', 'septilhões', 'octilhões', 'nonilhões', 'decilhões']);

const MAX_VALUE = 10n ** 36n - 1n;

const group = (n) => Math.ceil(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));

const hundredPt = (n) => {
if (n < 100 || n >= 1000) return '';
const h = Math.floor(n / 100);
const remainder = n % 100;
// "cem" when exactly 100, "cento" otherwise
if (h === 1 && remainder === 0) return 'cem';
return PT_HUNDREDS[h];
};

const tenPt = (n) => {
if (n === 0) return '';
if (n < 10) return PT_ONES[n];
if (n < 20) return PT_TEENS[n - 10];
const onesDigit = n % 10;
if (onesDigit) return `${PT_TENS[Math.floor(n / 10)]} e ${PT_ONES[onesDigit]}`;
return PT_TENS[Math.floor(n / 10)];
};

const cap = (str, style) => {
switch (style) {
case 'title':
return str.replace(/\w([^-\s]*)/g, (txt) =>
txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()
);
case 'upper': return str.toUpperCase();
case 'lower': return str.toLowerCase();
default: return str;
}
};

/**
* Convert a number to Portuguese words
* @param {number|bigint} n - The number to convert
* @param {Object} [opt] - Options object
* @returns {string|false} The Portuguese word representation
*
* @example
* portuguese(42) // 'quarenta e dois'
* portuguese(1000) // 'mil'
*/
const portuguese = (n, opt) => {
let num;

if (typeof n === 'bigint') {
if (n < 0n || n > MAX_VALUE) return false;
num = n;
} else if (typeof n === 'number') {
if (isNaN(n) || n < 0 || !Number.isInteger(n)) return false;
if (n > Number.MAX_SAFE_INTEGER) return false;
num = BigInt(n);
} else {
return false;
}

if (num === 0n) return 'zero';
if (num === 1n) return 'um';

const parts = [];

for (let i = group(num); i >= 0; i--) {
const h = hundment(num, i);
if (h > 0) {
let part = '';

const hundreds = Math.floor(h / 100);
const tens = h % 100;

if (hundreds > 0) {
part += hundredPt(h);
if (tens > 0) part += ' e ';
}

if (tens > 0) {
part += tenPt(tens);
}

if (i > 0) {
// Add scale word (mil, milhão, etc.)
if (i === 1) {
// "mil" doesn't change for plural and doesn't need "um" before it
if (h === 1) {
part = 'mil';
} else {
part += ' mil';
}
} else {
// milhão, bilhão, etc. - use singular for 1, plural for others
const scaleWord = h === 1 ? PT_ILLIONS[i] : PT_ILLIONS_PLURAL[i];
part += ` ${scaleWord}`;
}
}

parts.push(part);
}
}

// Join parts with appropriate connectors
let result = '';
for (let i = 0; i < parts.length; i++) {
if (i > 0) {
const prevPart = parts[i - 1];
const currPart = parts[i];

// Use "e" (and) for connecting in Portuguese when:
// 1. Current part has no scale word (final small numbers)
// 2. Previous part has a higher scale (milhão+) and current part has "mil"
const prevHasHigherScale = prevPart && (prevPart.includes('ilhão') || prevPart.includes('ilhões'));
const currHasMil = currPart && currPart.includes('mil');
const currHasNoScale = currPart && !currHasMil && !currPart.includes('ilhão') && !currPart.includes('ilhões');

if (currHasNoScale || (prevHasHigherScale && currHasMil)) {
result += ' e ';
} else {
result += ' ';
}
}
result += parts[i];
}

result = result.trim();
if (opt?.cap) result = cap(result, opt.cap);
return result;
};

export default portuguese;
export { portuguese };
Loading