Skip to content

Commit 6dcfe3e

Browse files
brianfunkclaude
andcommitted
feat: add Portuguese language support and server tests
- Added Portuguese (pt) language with full number-to-words support - Added 34 server API tests with supertest - Updated README to document 9 languages and REST API - Fixed server to return 400 for invalid inputs (not 500) - Added Russian to server /languages endpoint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 76adb13 commit 6dcfe3e

8 files changed

Lines changed: 2428 additions & 21 deletions

File tree

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
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
2525
export { spanish, french, german, danish, chinese, hindi, russian };
@@ -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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
const tenment = (n, g) => hundment(n, g) % 100;
20+
21+
const hundredPt = (n) => {
22+
if (n < 100 || n >= 1000) return '';
23+
const h = Math.floor(n / 100);
24+
const remainder = n % 100;
25+
// "cem" when exactly 100, "cento" otherwise
26+
if (h === 1 && remainder === 0) return 'cem';
27+
return PT_HUNDREDS[h];
28+
};
29+
30+
const tenPt = (n) => {
31+
if (n === 0) return '';
32+
if (n < 10) return PT_ONES[n];
33+
if (n < 20) return PT_TEENS[n - 10];
34+
const onesDigit = n % 10;
35+
if (onesDigit) return `${PT_TENS[Math.floor(n / 10)]} e ${PT_ONES[onesDigit]}`;
36+
return PT_TENS[Math.floor(n / 10)];
37+
};
38+
39+
const cap = (str, style) => {
40+
switch (style) {
41+
case 'title':
42+
return str.replace(/\w([^-\s]*)/g, (txt) =>
43+
txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()
44+
);
45+
case 'upper': return str.toUpperCase();
46+
case 'lower': return str.toLowerCase();
47+
default: return str;
48+
}
49+
};
50+
51+
/**
52+
* Convert a number to Portuguese words
53+
* @param {number|bigint} n - The number to convert
54+
* @param {Object} [opt] - Options object
55+
* @returns {string|false} The Portuguese word representation
56+
*
57+
* @example
58+
* portuguese(42) // 'quarenta e dois'
59+
* portuguese(1000) // 'mil'
60+
*/
61+
const portuguese = (n, opt) => {
62+
let num;
63+
64+
if (typeof n === 'bigint') {
65+
if (n < 0n || n > MAX_VALUE) return false;
66+
num = n;
67+
} else if (typeof n === 'number') {
68+
if (isNaN(n) || n < 0 || !Number.isInteger(n)) return false;
69+
if (n > Number.MAX_SAFE_INTEGER) return false;
70+
num = BigInt(n);
71+
} else {
72+
return false;
73+
}
74+
75+
if (num === 0n) return 'zero';
76+
if (num === 1n) return 'um';
77+
78+
const parts = [];
79+
80+
for (let i = group(num); i >= 0; i--) {
81+
const h = hundment(num, i);
82+
if (h > 0) {
83+
let part = '';
84+
85+
const hundreds = Math.floor(h / 100);
86+
const tens = h % 100;
87+
88+
if (hundreds > 0) {
89+
part += hundredPt(h);
90+
if (tens > 0) part += ' e ';
91+
}
92+
93+
if (tens > 0) {
94+
part += tenPt(tens);
95+
}
96+
97+
if (i > 0) {
98+
// Add scale word (mil, milhão, etc.)
99+
if (i === 1) {
100+
// "mil" doesn't change for plural and doesn't need "um" before it
101+
if (h === 1) {
102+
part = 'mil';
103+
} else {
104+
part += ' mil';
105+
}
106+
} else {
107+
// milhão, bilhão, etc. - use singular for 1, plural for others
108+
const scaleWord = h === 1 ? PT_ILLIONS[i] : PT_ILLIONS_PLURAL[i];
109+
part += ` ${scaleWord}`;
110+
}
111+
}
112+
113+
parts.push(part);
114+
}
115+
}
116+
117+
// Join parts with appropriate connectors
118+
let result = '';
119+
for (let i = 0; i < parts.length; i++) {
120+
if (i > 0) {
121+
// Use "e" (and) for connecting in Portuguese
122+
const prevPart = parts[i - 1];
123+
const currPart = parts[i];
124+
// Add "e" when the current part is less than 100 or ends with 00
125+
if (currPart && !currPart.includes('mil') && !currPart.includes('ilhão') && !currPart.includes('ilhões')) {
126+
result += ' e ';
127+
} else {
128+
result += ' ';
129+
}
130+
}
131+
result += parts[i];
132+
}
133+
134+
result = result.trim();
135+
if (opt?.cap) result = cap(result, opt.cap);
136+
return result;
137+
};
138+
139+
export default portuguese;
140+
export { portuguese };

server/index.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ export const createApp = () => {
7373
{ code: 'de', name: 'German (Deutsch)' },
7474
{ code: 'da', name: 'Danish (Dansk)' },
7575
{ code: 'zh', name: 'Chinese (中文)' },
76-
{ code: 'hi', name: 'Hindi (हिन्दी)' }
76+
{ code: 'hi', name: 'Hindi (हिन्दी)' },
77+
{ code: 'ru', name: 'Russian (Русский)' },
78+
{ code: 'pt', name: 'Portuguese (Português)' }
7779
]
7880
});
7981
});
@@ -84,7 +86,18 @@ export const createApp = () => {
8486
const { number } = req.params;
8587
const { lang, cap: capStyle, punc } = req.query;
8688

87-
const num = number.includes('n') ? BigInt(number.replace('n', '')) : parseInt(number, 10);
89+
let num;
90+
try {
91+
num = number.includes('n') ? BigInt(number.replace('n', '')) : parseInt(number, 10);
92+
} catch {
93+
res.status(400).json({ error: 'Invalid number', input: number });
94+
return;
95+
}
96+
97+
if (typeof num === 'number' && isNaN(num)) {
98+
res.status(400).json({ error: 'Invalid number', input: number });
99+
return;
100+
}
88101

89102
const opt = {};
90103
if (lang) opt.lang = lang;

0 commit comments

Comments
 (0)