Skip to content

Commit 284f71e

Browse files
authored
Merge pull request #7 from brianfunk/dev
feat: Portuguese support + server tests
2 parents 76adb13 + dd6ac9b commit 284f71e

12 files changed

Lines changed: 3722 additions & 64 deletions

File tree

CLAUDE.md

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,37 @@ Key constants:
3232

3333
## When Making Changes
3434

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

41-
## Known Limitations
40+
## PR Review Workflow
4241

43-
- Maximum supported number: `Number.MAX_SAFE_INTEGER` (9,007,199,254,740,991)
44-
- No decimal support
45-
- English only
46-
- No ordinal support (1st, 2nd, 3rd)
42+
- Always check GitHub PR comments before continuing work
43+
- Review feedback from Codex, human reviewers, and CI systems
44+
- Fix valid issues before pushing new commits
45+
- Use `gh pr view <number> --comments` to fetch PR comments
46+
47+
## Supported Languages
48+
49+
- English (default)
50+
- Spanish (`es`, `spanish`, `español`)
51+
- French (`fr`, `french`, `français`)
52+
- German (`de`, `german`, `deutsch`)
53+
- Danish (`da`, `danish`, `dansk`)
54+
- Chinese (`zh`, `chinese`, `中文`)
55+
- Hindi (`hi`, `hindi`, `हिन्दी`)
56+
- Russian (`ru`, `russian`, `русский`)
57+
- Portuguese (`pt`, `portuguese`, `português`)
58+
59+
## Features
60+
61+
- Number to words (cardinal)
62+
- Ordinals (1st, 2nd, 3rd)
63+
- Decimals (3.14 → "three point one four")
64+
- Currency ($1.23 → "one dollar and twenty-three cents")
65+
- Fractions (1/2 → "one half")
66+
- Roman numerals (42 → "XLII")
67+
- Negative numbers
68+
- BigInt support up to 10^36

README.md

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100
1616
## Why numberstring?
1717

1818
- **Zero dependencies** - Lightweight and fast
19-
- **8 languages** - English, Spanish, French, German, Danish, Chinese, Hindi, Russian
19+
- **9 languages** - English, Spanish, French, German, Danish, Chinese, Hindi, Russian, Portuguese
2020
- **Huge range** - Supports 0 to decillions (10^36) with BigInt
2121
- **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers
2222
- **Roman numerals** - Convert to and from Roman numerals
@@ -191,10 +191,10 @@ comma(1234567); // '1,234,567'
191191

192192
## Multi-Language Support
193193

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

196196
```javascript
197-
import { toWords, spanish, french, german, danish, chinese, hindi, russian } from 'numberstring';
197+
import { toWords, spanish, french, german, danish, chinese, hindi, russian, portuguese } from 'numberstring';
198198

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

208209
// Or use language functions directly
209-
spanish(1000); // 'mil'
210-
french(80); // 'quatre-vingts'
211-
german(21); // 'einundzwanzig'
212-
chinese(10000); // '一万'
213-
russian(2000); // 'две тысячи'
210+
spanish(1000); // 'mil'
211+
french(80); // 'quatre-vingts'
212+
german(21); // 'einundzwanzig'
213+
chinese(10000); // '一万'
214+
russian(2000); // 'две тысячи'
215+
portuguese(100); // 'cem'
214216
```
215217

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

254+
## REST API Server
255+
256+
numberstring includes a ready-to-use REST API server!
257+
258+
```bash
259+
cd server
260+
npm install
261+
npm start
262+
# Server running at http://localhost:3456
263+
```
264+
265+
### Endpoints
266+
267+
| Endpoint | Description | Example |
268+
|----------|-------------|---------|
269+
| `GET /convert/:n` | Number to words | `/convert/42``"forty-two"` |
270+
| `GET /convert/:n?lang=es` | Multi-language | `/convert/42?lang=es``"cuarenta y dos"` |
271+
| `GET /ordinal/:n` | Ordinal words | `/ordinal/3``"third"` |
272+
| `GET /decimal/:n` | Decimal words | `/decimal/3.14``"three point one four"` |
273+
| `GET /currency/:amt` | Currency words | `/currency/$99.99``"ninety-nine dollars..."` |
274+
| `GET /roman/:n` | Roman numerals | `/roman/2024``"MMXXIV"` |
275+
| `GET /parse/:words` | Words to number | `/parse/forty-two``42` |
276+
| `GET /comma/:n` | Format with commas | `/comma/1000000``"1,000,000"` |
277+
| `GET /languages` | List languages | Returns supported language codes |
278+
279+
### Example
280+
281+
```bash
282+
curl http://localhost:3456/convert/1000000000000000
283+
# {"input":"1000000000000000","output":"one quadrillion","lang":"en"}
284+
285+
curl "http://localhost:3456/convert/42?lang=fr"
286+
# {"input":"42","output":"quarante-deux","lang":"fr"}
287+
```
288+
252289
## Development
253290

254291
```bash

index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
*/
2020

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

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

2727
// ============================================================================
2828
// CONSTANTS
@@ -630,6 +630,9 @@ const toWords = (n, opt) => {
630630
case 'russian':
631631
result = russian(n);
632632
break;
633+
case 'portuguese':
634+
result = portuguese(n);
635+
break;
633636
default:
634637
result = english(n);
635638
}

languages/index.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import danish from './da.js';
1919
import chinese from './zh.js';
2020
import hindi from './hi.js';
2121
import russian from './ru.js';
22+
import portuguese from './pt.js';
2223

2324
/** Supported language codes and aliases */
2425
const LANGUAGES = Object.freeze({
@@ -45,7 +46,10 @@ const LANGUAGES = Object.freeze({
4546
'हिन्दी': 'hindi',
4647
ru: 'russian',
4748
russian: 'russian',
48-
'русский': 'russian'
49+
'русский': 'russian',
50+
pt: 'portuguese',
51+
portuguese: 'portuguese',
52+
português: 'portuguese'
4953
});
5054

5155
export {
@@ -57,5 +61,6 @@ export {
5761
chinese,
5862
hindi,
5963
russian,
64+
portuguese,
6065
LANGUAGES
6166
};

languages/pt.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Portuguese number-to-words converter
3+
* @module languages/pt
4+
*/
5+
6+
const PT_ONES = Object.freeze(['', 'um', 'dois', 'três', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove']);
7+
const PT_TENS = Object.freeze(['', '', 'vinte', 'trinta', 'quarenta', 'cinquenta', 'sessenta', 'setenta', 'oitenta', 'noventa']);
8+
const PT_TEENS = Object.freeze(['dez', 'onze', 'doze', 'treze', 'catorze', 'quinze', 'dezesseis', 'dezessete', 'dezoito', 'dezenove']);
9+
const PT_HUNDREDS = Object.freeze(['', 'cento', 'duzentos', 'trezentos', 'quatrocentos', 'quinhentos', 'seiscentos', 'setecentos', 'oitocentos', 'novecentos']);
10+
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']);
11+
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']);
12+
13+
const MAX_VALUE = 10n ** 36n - 1n;
14+
15+
const group = (n) => Math.ceil(n.toString().length / 3) - 1;
16+
const power = (g) => 10n ** BigInt(g * 3);
17+
const segment = (n, g) => n % power(g + 1);
18+
const hundment = (n, g) => Number(segment(n, g) / power(g));
19+
20+
const hundredPt = (n) => {
21+
if (n < 100 || n >= 1000) return '';
22+
const h = Math.floor(n / 100);
23+
const remainder = n % 100;
24+
// "cem" when exactly 100, "cento" otherwise
25+
if (h === 1 && remainder === 0) return 'cem';
26+
return PT_HUNDREDS[h];
27+
};
28+
29+
const tenPt = (n) => {
30+
if (n === 0) return '';
31+
if (n < 10) return PT_ONES[n];
32+
if (n < 20) return PT_TEENS[n - 10];
33+
const onesDigit = n % 10;
34+
if (onesDigit) return `${PT_TENS[Math.floor(n / 10)]} e ${PT_ONES[onesDigit]}`;
35+
return PT_TENS[Math.floor(n / 10)];
36+
};
37+
38+
const cap = (str, style) => {
39+
switch (style) {
40+
case 'title':
41+
return str.replace(/\w([^-\s]*)/g, (txt) =>
42+
txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()
43+
);
44+
case 'upper': return str.toUpperCase();
45+
case 'lower': return str.toLowerCase();
46+
default: return str;
47+
}
48+
};
49+
50+
/**
51+
* Convert a number to Portuguese words
52+
* @param {number|bigint} n - The number to convert
53+
* @param {Object} [opt] - Options object
54+
* @returns {string|false} The Portuguese word representation
55+
*
56+
* @example
57+
* portuguese(42) // 'quarenta e dois'
58+
* portuguese(1000) // 'mil'
59+
*/
60+
const portuguese = (n, opt) => {
61+
let num;
62+
63+
if (typeof n === 'bigint') {
64+
if (n < 0n || n > MAX_VALUE) return false;
65+
num = n;
66+
} else if (typeof n === 'number') {
67+
if (isNaN(n) || n < 0 || !Number.isInteger(n)) return false;
68+
if (n > Number.MAX_SAFE_INTEGER) return false;
69+
num = BigInt(n);
70+
} else {
71+
return false;
72+
}
73+
74+
if (num === 0n) return 'zero';
75+
if (num === 1n) return 'um';
76+
77+
const parts = [];
78+
79+
for (let i = group(num); i >= 0; i--) {
80+
const h = hundment(num, i);
81+
if (h > 0) {
82+
let part = '';
83+
84+
const hundreds = Math.floor(h / 100);
85+
const tens = h % 100;
86+
87+
if (hundreds > 0) {
88+
part += hundredPt(h);
89+
if (tens > 0) part += ' e ';
90+
}
91+
92+
if (tens > 0) {
93+
part += tenPt(tens);
94+
}
95+
96+
if (i > 0) {
97+
// Add scale word (mil, milhão, etc.)
98+
if (i === 1) {
99+
// "mil" doesn't change for plural and doesn't need "um" before it
100+
if (h === 1) {
101+
part = 'mil';
102+
} else {
103+
part += ' mil';
104+
}
105+
} else {
106+
// milhão, bilhão, etc. - use singular for 1, plural for others
107+
const scaleWord = h === 1 ? PT_ILLIONS[i] : PT_ILLIONS_PLURAL[i];
108+
part += ` ${scaleWord}`;
109+
}
110+
}
111+
112+
parts.push(part);
113+
}
114+
}
115+
116+
// Join parts with appropriate connectors
117+
let result = '';
118+
for (let i = 0; i < parts.length; i++) {
119+
if (i > 0) {
120+
const prevPart = parts[i - 1];
121+
const currPart = parts[i];
122+
123+
// Use "e" (and) for connecting in Portuguese when:
124+
// 1. Current part has no scale word (final small numbers)
125+
// 2. Previous part has a higher scale (milhão+) and current part has "mil"
126+
const prevHasHigherScale = prevPart && (prevPart.includes('ilhão') || prevPart.includes('ilhões'));
127+
const currHasMil = currPart && currPart.includes('mil');
128+
const currHasNoScale = currPart && !currHasMil && !currPart.includes('ilhão') && !currPart.includes('ilhões');
129+
130+
if (currHasNoScale || (prevHasHigherScale && currHasMil)) {
131+
result += ' e ';
132+
} else {
133+
result += ' ';
134+
}
135+
}
136+
result += parts[i];
137+
}
138+
139+
result = result.trim();
140+
if (opt?.cap) result = cap(result, opt.cap);
141+
return result;
142+
};
143+
144+
export default portuguese;
145+
export { portuguese };

0 commit comments

Comments
 (0)