n2cur 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,246 @@
1
- # Temporary Holding Version
1
+ # n2cur
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">
4
+ <img src="./assets/og-image.png" alt="n2cur - Đọc số tiền thành chữ tiếng Việt" width="100%" />
5
+ </p>
6
+
7
+ <p align="center">
8
+ <strong>Thư viện JavaScript / TypeScript đọc số tiền thành chữ tiếng Việt chuẩn xác 100%</strong><br>
9
+ Hỗ trợ <strong>React</strong>, <strong>Angular</strong>, <strong>Vue 3</strong>, <strong>Vanilla JS / Node.js</strong> và nhúng <strong>CDN</strong>.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://github.com/dongtrieuit/n2cur"><img src="https://img.shields.io/github/license/dongtrieuit/n2cur?color=4f46e5" alt="License" /></a>
14
+ <a href="https://www.npmjs.com/package/n2cur"><img src="https://img.shields.io/npm/v/n2cur?color=0891b2" alt="Version" /></a>
15
+ <a href="https://github.com/dongtrieuit/n2cur"><img src="https://img.shields.io/badge/types-TypeScript-blue" alt="TypeScript" /></a>
16
+ <a href="https://github.com/dongtrieuit/n2cur"><img src="https://img.shields.io/badge/author-dongtrieuit-111827" alt="Author" /></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="https://n2cur.dts.io.vn/" target="_blank"><strong>🌐 Website Demo Dùng Thử Trực Tuyến: https://n2cur.dts.io.vn/</strong></a>
21
+ </p>
22
+
23
+ > Đang làm tính năng hóa đơn/thanh toán mà đau đầu vụ đọc số tiền thành chữ? Thôi đừng tự code lại nữa mấy bác! Bộ quy tắc trong n2cur đã được tối ưu qua vô số vòng nghiệm thu khó tính từ BU ngân hàng. Cài 1 dòng `npm i n2cur` là xong ngay!
24
+
25
+ ---
26
+
27
+ ## Tính năng nổi bật
28
+
29
+ - **Siêu nhẹ & Zero-Dependency**: Chỉ ~11 KB không phụ thuộc bất kỳ thư viện bên thứ ba nào.
30
+ - **Chính xác 100% tiếng Việt**: Đọc chuẩn các quy tắc `"không trăm"`, `"linh/lẻ"`, `"mốt/tư/lăm"`, chu kỳ `"tỷ tỷ"` lặp lại.
31
+ - **Master Data API Linh hoạt**: Nạp và tùy biến danh mục tiền tệ (VND, USD, EUR, GBP, JPY, SGD...) động từ Backend API.
32
+ - **An toàn kiểu dữ liệu (Type-Safe)**: Hỗ trợ TypeScript type-defs đầy đủ, xử lý `BigInt` và số thực cực lớn mà không mất độ chính xác.
33
+ - **Đa nền tảng**: Hoạt động mượt mà trên React, Angular, Vue 3, Next.js, Nuxt, Node.js và trình duyệt qua CDN.
34
+
35
+ ---
36
+
37
+ ## Hướng dẫn cài đặt & tích hợp từng bước
38
+
39
+ ### 1. React (Next.js / Vite / CRA)
40
+
41
+ <p align="center">
42
+ <img src="./assets/react-demo.png" alt="React iMac M4 Setup Demo" width="100%" />
43
+ </p>
44
+
45
+ #### Bước 1: Cài đặt gói `n2cur`
46
+ ```bash
47
+ npm install n2cur
48
+ ```
49
+
50
+ #### Bước 2: Tạo Component đọc số tiền (`AmountInWords.tsx`)
51
+ ```tsx
52
+ import { useMemo } from 'react';
53
+ import { createMoneyReader, type CurrencyConfig } from 'n2cur';
54
+
55
+ export function AmountInWords({ amount, currency = 'VND', currencies }: {
56
+ amount: string | number;
57
+ currency?: string;
58
+ currencies?: CurrencyConfig[];
59
+ }) {
60
+ const reader = useMemo(() => createMoneyReader({ currencies }), [currencies]);
61
+ const res = reader.safeRead(amount, currency);
62
+
63
+ if (!res.ok) return <span className="error">{res.error.message}</span>;
64
+ return <span>{res.text}</span>;
65
+ }
66
+ ```
67
+
68
+ #### Bước 3: Sử dụng trong App
69
+ ```tsx
70
+ import { AmountInWords } from './AmountInWords';
71
+
72
+ export default function App() {
73
+ return <p>Tổng tiền: <AmountInWords amount="1005001" currency="VND" /></p>;
74
+ }
75
+ ```
76
+
77
+ #### Bước 4: Tùy chỉnh Master Data từ API (Tùy chọn)
78
+ ```tsx
79
+ const currencies = await fetch('/api/currencies').then(r => r.json());
80
+ <AmountInWords amount="50.5" currency="USD" currencies={currencies} />
81
+ ```
82
+
83
+ ---
84
+
85
+ ### 2. Angular (Component / Standalone Pipe)
86
+
87
+ <p align="center">
88
+ <img src="./assets/angular-demo.png" alt="Angular iMac M4 Setup Demo" width="100%" />
89
+ </p>
90
+
91
+ #### Bước 1: Cài đặt gói `n2cur`
92
+ ```bash
93
+ npm install n2cur
94
+ ```
95
+
96
+ #### Bước 2: Tạo Standalone Pipe (`money-words.pipe.ts`)
97
+ ```ts
98
+ import { Pipe, PipeTransform } from '@angular/core';
99
+ import { safeReadMoney } from 'n2cur';
100
+
101
+ @Pipe({
102
+ name: 'moneyWords',
103
+ standalone: true
104
+ })
105
+ export class MoneyWordsPipe implements PipeTransform {
106
+ transform(amount: string | number | null | undefined, currency = 'VND'): string {
107
+ if (amount === null || amount === undefined || amount === '') return '';
108
+ const res = safeReadMoney(amount, currency);
109
+ return res.ok ? res.text : '';
110
+ }
111
+ }
112
+ ```
113
+
114
+ #### Bước 3: Sử dụng trong HTML Template
115
+ ```html
116
+ <p>Thành tiền: {{ totalAmount | moneyWords:'VND' }}</p>
117
+ ```
118
+
119
+ ---
120
+
121
+ ### 3. Vue 3 (Composition API)
122
+
123
+ <p align="center">
124
+ <img src="./assets/vue-demo.png" alt="Vue 3 iMac M4 Setup Demo" width="100%" />
125
+ </p>
126
+
127
+ #### Bước 1: Cài đặt gói `n2cur`
128
+ ```bash
129
+ npm install n2cur
130
+ ```
131
+
132
+ #### Bước 2: Tạo Component (`AmountWords.vue`)
133
+ ```vue
134
+ <script setup lang="ts">
135
+ import { computed } from 'vue';
136
+ import { safeReadMoney } from 'n2cur';
137
+
138
+ const props = defineProps<{
139
+ amount: string | number;
140
+ currency?: string;
141
+ }>();
142
+
143
+ const text = computed(() => {
144
+ const res = safeReadMoney(props.amount, props.currency || 'VND');
145
+ return res.ok ? res.text : res.error.message;
146
+ });
147
+ </script>
148
+
149
+ <template>
150
+ <span>{{ text }}</span>
151
+ </template>
152
+ ```
153
+
154
+ ---
155
+
156
+ ### 4. Vanilla JavaScript & TypeScript
157
+
158
+ <p align="center">
159
+ <img src="./assets/vanilla-demo.png" alt="Vanilla JS iMac M4 Setup Demo" width="100%" />
160
+ </p>
161
+
162
+ #### Bước 1: Cài đặt gói `n2cur`
163
+ ```bash
164
+ npm install n2cur
165
+ ```
166
+
167
+ #### Bước 2: Đọc số tiền trực tiếp
168
+ ```ts
169
+ import { readMoney, safeReadMoney } from 'n2cur';
170
+
171
+ // Đọc nhanh số tiền mặc định (VND, USD, EUR, GBP, JPY, SGD)
172
+ console.log(readMoney(1005001, 'VND'));
173
+ // -> "Một triệu không trăm linh năm nghìn không trăm lẻ một đồng"
174
+
175
+ console.log(readMoney('12.02', 'GBP'));
176
+ // -> "Mười hai bảng Anh và hai pence"
177
+
178
+ // Safe read không ném Exception khi nhập dữ liệu lỗi
179
+ const res = safeReadMoney(100, 'USD');
180
+ if (res.ok) console.log(res.text);
181
+ ```
182
+
183
+ #### Bước 3: Đọc phần lẻ có dấu phẩy `,` kiểu Việt Nam
184
+ ```ts
185
+ import { readMoney } from 'n2cur';
186
+
187
+ readMoney('1.005.001,25', 'USD', { decimalSeparator: ',' });
188
+ // -> "Một triệu không trăm linh năm đô la Mỹ và hai mươi lăm cent"
189
+ ```
190
+
191
+ ---
192
+
193
+ ### 5. Nhúng trực tiếp CDN (HTML)
194
+
195
+ <p align="center">
196
+ <img src="./assets/cdn-demo.png" alt="CDN iMac M4 Setup Demo" width="100%" />
197
+ </p>
198
+
199
+ #### Bước 1: Nhúng qua CDN `unpkg`
200
+ ```html
201
+ <script src="https://unpkg.com/n2cur/dist/n2cur.global.js"></script>
202
+ ```
203
+
204
+ #### Bước 2: Sử dụng qua biến toàn cục `window.N2Cur`
205
+ ```html
206
+ <script>
207
+ const text = N2Cur.readMoney(1005001, 'VND');
208
+ document.getElementById('total-words').textContent = text;
209
+ </script>
210
+ ```
211
+
212
+ ---
213
+
214
+ ## Cấu hình Master Data tiền tệ (API Schema)
215
+
216
+ | Trường | Kiểu dữ liệu | Mô tả chi tiết |
217
+ | :--- | :--- | :--- |
218
+ | `currency_code` | `string` | Mã tiền tệ chuẩn ISO 4217 (`VND`, `USD`, `EUR`...) |
219
+ | `currency_name` | `string` | Tên đơn vị chính (`đồng`, `đô la Mỹ`, `bảng Anh`...) |
220
+ | `decimal_handling` | `'READ' \| 'IGNORE' \| 'REJECT'` | Cách xử lý phần lẻ (`READ`: đọc lẻ, `IGNORE`: bỏ qua, `REJECT`: từ chối) |
221
+ | `decimal_scale` | `number` | Số chữ số tối đa phần lẻ (vd: 2 chữ số cent) |
222
+ | `decimal_allowed` | `boolean` | Cho phép đọc phần lẻ ra chữ hay không |
223
+ | `minor_unit_singular` | `string` | Đơn vị lẻ số ít (`cent`, `penny`...) |
224
+ | `minor_unit_plural` | `string` | Đơn vị lẻ số nhiều (`cents`, `pence`...) |
225
+ | `active` | `boolean` | Trạng thái kích hoạt (`true` mới có hiệu lực) |
226
+
227
+ ---
228
+
229
+ ## API Reference
230
+
231
+ ### 1. `readMoney(amount, currencyCode, options?)`
232
+ Đọc số tiền và trả về chuỗi kết quả tiếng Việt. Ném lỗi `MoneyReaderError` nếu tiền tệ không hợp lệ.
233
+
234
+ ### 2. `safeReadMoney(amount, currencyCode, options?)`
235
+ Đọc số tiền an toàn. Trả về `{ ok: true, text }` hoặc `{ ok: false, error }`.
236
+
237
+ ### 3. `createMoneyReader({ currencies })`
238
+ Tạo instance đọc tiền với danh mục Master Data riêng từ API.
239
+
240
+ ---
241
+
242
+ ## License & Tác giả
243
+
244
+ Phát triển bởi **[dongtrieuit](https://github.com/dongtrieuit)**.
245
+
246
+ Mã nguồn mở cấp phép theo giấy phép [MIT License](LICENSE). Repository chính thức: [https://github.com/dongtrieuit/n2cur.git](https://github.com/dongtrieuit/n2cur.git).
package/dist/index.cjs ADDED
@@ -0,0 +1,325 @@
1
+ 'use strict';
2
+
3
+ // src/currencies.ts
4
+ var DEFAULT_CURRENCIES = Object.freeze([
5
+ {
6
+ currency_code: "VND",
7
+ currency_name: "\u0111\u1ED3ng",
8
+ decimal_handling: "IGNORE",
9
+ decimal_scale: 0,
10
+ decimal_allowed: false,
11
+ minor_unit_singular: null,
12
+ minor_unit_plural: null,
13
+ active: true
14
+ },
15
+ {
16
+ currency_code: "USD",
17
+ currency_name: "\u0111\xF4 la M\u1EF9",
18
+ decimal_handling: "READ",
19
+ decimal_scale: 2,
20
+ decimal_allowed: true,
21
+ minor_unit_singular: "cent",
22
+ minor_unit_plural: "cent",
23
+ active: true
24
+ },
25
+ {
26
+ currency_code: "EUR",
27
+ currency_name: "euro",
28
+ decimal_handling: "READ",
29
+ decimal_scale: 2,
30
+ decimal_allowed: true,
31
+ minor_unit_singular: "cent",
32
+ minor_unit_plural: "cent",
33
+ active: true
34
+ },
35
+ {
36
+ currency_code: "GBP",
37
+ currency_name: "b\u1EA3ng Anh",
38
+ decimal_handling: "READ",
39
+ decimal_scale: 2,
40
+ decimal_allowed: true,
41
+ minor_unit_singular: "penny",
42
+ minor_unit_plural: "pence",
43
+ active: true
44
+ },
45
+ {
46
+ currency_code: "JPY",
47
+ currency_name: "y\xEAn Nh\u1EADt",
48
+ decimal_handling: "IGNORE",
49
+ decimal_scale: 0,
50
+ decimal_allowed: false,
51
+ minor_unit_singular: null,
52
+ minor_unit_plural: null,
53
+ active: true
54
+ },
55
+ {
56
+ currency_code: "SGD",
57
+ currency_name: "\u0111\xF4 la Singapore",
58
+ decimal_handling: "READ",
59
+ decimal_scale: 2,
60
+ decimal_allowed: true,
61
+ minor_unit_singular: "cent",
62
+ minor_unit_plural: "cent",
63
+ active: true
64
+ }
65
+ ]);
66
+
67
+ // src/errors.ts
68
+ var MoneyReaderError = class extends Error {
69
+ constructor(code, message, details) {
70
+ super(message);
71
+ this.name = "MoneyReaderError";
72
+ this.code = code;
73
+ this.details = details;
74
+ Object.setPrototypeOf(this, new.target.prototype);
75
+ }
76
+ };
77
+ function isMoneyReaderError(err) {
78
+ return err instanceof MoneyReaderError;
79
+ }
80
+
81
+ // src/parse-amount.ts
82
+ var invalid = (input, reason) => new MoneyReaderError("INVALID_AMOUNT", `S\u1ED1 ti\u1EC1n kh\xF4ng h\u1EE3p l\u1EC7: ${reason}`, { input: String(input) });
83
+ function expandExponent(str) {
84
+ const match = /^(\d+)(?:\.(\d+))?e([+-]\d+)$/i.exec(str);
85
+ if (!match) return str;
86
+ const intPart = match[1] ?? "";
87
+ const fracPart = match[2] ?? "";
88
+ const exp = Number(match[3]);
89
+ const digits = intPart + fracPart;
90
+ const pointPos = intPart.length + exp;
91
+ if (pointPos <= 0) return `0.${"0".repeat(-pointPos)}${digits}`;
92
+ if (pointPos >= digits.length) return digits + "0".repeat(pointPos - digits.length);
93
+ return `${digits.slice(0, pointPos)}.${digits.slice(pointPos)}`;
94
+ }
95
+ function normalizeParts(integer, fraction) {
96
+ return {
97
+ integer: integer.replace(/^0+(?=\d)/, "") || "0",
98
+ fraction: fraction.replace(/0+$/, "")
99
+ };
100
+ }
101
+ function parseAmount(input, options = {}) {
102
+ if (typeof input === "bigint") {
103
+ if (input < 0n) throw invalid(input, "kh\xF4ng h\u1ED7 tr\u1EE3 s\u1ED1 \xE2m");
104
+ return normalizeParts(input.toString(), "");
105
+ }
106
+ if (typeof input === "number") {
107
+ if (!Number.isFinite(input)) throw invalid(input, "ph\u1EA3i l\xE0 s\u1ED1 h\u1EEFu h\u1EA1n");
108
+ if (input < 0) throw invalid(input, "kh\xF4ng h\u1ED7 tr\u1EE3 s\u1ED1 \xE2m");
109
+ const [i = "0", f = ""] = expandExponent(String(Math.abs(input))).split(".");
110
+ return normalizeParts(i, f);
111
+ }
112
+ if (typeof input !== "string") throw invalid(input, "ph\u1EA3i l\xE0 number, bigint ho\u1EB7c string");
113
+ const decimalSeparator = options.decimalSeparator ?? ".";
114
+ if (decimalSeparator !== "." && decimalSeparator !== ",") {
115
+ throw new MoneyReaderError("INVALID_CONFIG", `decimalSeparator kh\xF4ng h\u1EE3p l\u1EC7: ${String(decimalSeparator)}`);
116
+ }
117
+ const groupSeparator = decimalSeparator === "." ? "," : ".";
118
+ let str = input.replace(/\s+/g, "");
119
+ if (str.startsWith("+")) str = str.slice(1);
120
+ if (str.startsWith("-")) throw invalid(input, "kh\xF4ng h\u1ED7 tr\u1EE3 s\u1ED1 \xE2m");
121
+ const g = `\\${groupSeparator}`;
122
+ const d = `\\${decimalSeparator}`;
123
+ const pattern = new RegExp(`^(\\d{1,3}(?:${g}\\d{3})+|\\d*)(?:${d}(\\d*))?$`);
124
+ const match = pattern.exec(str);
125
+ const intRaw = match?.[1] ?? "";
126
+ const fracRaw = match?.[2] ?? "";
127
+ if (!match || intRaw === "" && fracRaw === "") {
128
+ throw invalid(input, "\u0111\u1ECBnh d\u1EA1ng kh\xF4ng \u0111\xFAng");
129
+ }
130
+ return normalizeParts(intRaw.split(groupSeparator).join("") || "0", fracRaw);
131
+ }
132
+
133
+ // src/read-integer.ts
134
+ var DIGITS = ["kh\xF4ng", "m\u1ED9t", "hai", "ba", "b\u1ED1n", "n\u0103m", "s\xE1u", "b\u1EA3y", "t\xE1m", "ch\xEDn"];
135
+ var BASE_SCALES = ["", "ngh\xECn", "tri\u1EC7u"];
136
+ var digit = (n) => DIGITS[n];
137
+ function scaleName(index) {
138
+ const parts = [];
139
+ const base = BASE_SCALES[index % 3];
140
+ if (base) parts.push(base);
141
+ for (let i = 0; i < Math.floor(index / 3); i++) parts.push("t\u1EF7");
142
+ return parts.join(" ");
143
+ }
144
+ function readGroup(h, t, u, ctx) {
145
+ const words = [];
146
+ if (h > 0 || ctx.full) words.push(digit(h), "tr\u0103m");
147
+ if (t === 0) {
148
+ if (u > 0) {
149
+ if (words.length > 0) words.push(ctx.vndStyle && ctx.isUnitGroup ? "l\u1EBB" : "linh");
150
+ words.push(digit(u));
151
+ }
152
+ } else if (t === 1) {
153
+ words.push("m\u01B0\u1EDDi");
154
+ if (u === 5) words.push("l\u0103m");
155
+ else if (u > 0) words.push(digit(u));
156
+ } else {
157
+ words.push(digit(t), "m\u01B0\u01A1i");
158
+ if (u === 1) words.push("m\u1ED1t");
159
+ else if (u === 4) words.push(ctx.vndStyle ? "t\u01B0" : "b\u1ED1n");
160
+ else if (u === 5) words.push("l\u0103m");
161
+ else if (u > 0) words.push(digit(u));
162
+ }
163
+ return words;
164
+ }
165
+ function readDigitsVi(digits, options = {}) {
166
+ if (!/^\d+$/.test(digits)) throw new TypeError(`Chu\u1ED7i s\u1ED1 kh\xF4ng h\u1EE3p l\u1EC7: "${digits}"`);
167
+ const normalized = digits.replace(/^0+(?=\d)/, "");
168
+ if (/^0+$/.test(normalized)) return digit(0);
169
+ const vndStyle = options.vndStyle === true;
170
+ const padded = normalized.padStart(Math.ceil(normalized.length / 3) * 3, "0");
171
+ const groupCount = padded.length / 3;
172
+ const words = [];
173
+ for (let g = 0; g < groupCount; g++) {
174
+ const index = groupCount - 1 - g;
175
+ const chunk = padded.slice(g * 3, g * 3 + 3);
176
+ const h = Number(chunk[0]);
177
+ const t = Number(chunk[1]);
178
+ const u = Number(chunk[2]);
179
+ if (h === 0 && t === 0 && u === 0) continue;
180
+ words.push(...readGroup(h, t, u, { full: words.length > 0, isUnitGroup: index === 0, vndStyle }));
181
+ const scale = scaleName(index);
182
+ if (scale) words.push(scale);
183
+ }
184
+ return words.join(" ");
185
+ }
186
+ function readInteger(value, options = {}) {
187
+ let digits;
188
+ if (typeof value === "bigint") {
189
+ if (value < 0n) throw new RangeError("Kh\xF4ng h\u1ED7 tr\u1EE3 s\u1ED1 \xE2m");
190
+ digits = value.toString();
191
+ } else if (typeof value === "number") {
192
+ if (!Number.isSafeInteger(value) || value < 0) {
193
+ throw new RangeError("Gi\xE1 tr\u1ECB number ph\u1EA3i l\xE0 s\u1ED1 nguy\xEAn kh\xF4ng \xE2m an to\xE0n; d\xF9ng bigint/string cho s\u1ED1 l\u1EDBn");
194
+ }
195
+ digits = String(value);
196
+ } else {
197
+ digits = value.trim();
198
+ }
199
+ return readDigitsVi(digits, options);
200
+ }
201
+
202
+ // src/money-reader.ts
203
+ var DECIMAL_HANDLINGS = ["READ", "IGNORE", "REJECT"];
204
+ var VND = "VND";
205
+ function normalizeCurrencyCode(code) {
206
+ return String(code ?? "").trim().toUpperCase();
207
+ }
208
+ var isBlank = (s) => !s || !s.trim();
209
+ function validateCurrency(c) {
210
+ const code = normalizeCurrencyCode(c.currency_code);
211
+ const fail = (msg) => {
212
+ throw new MoneyReaderError("INVALID_CONFIG", `C\u1EA5u h\xECnh ti\u1EC1n t\u1EC7 ${code} kh\xF4ng h\u1EE3p l\u1EC7: ${msg}`, { currency_code: code });
213
+ };
214
+ if (isBlank(c.currency_name)) fail("thi\u1EBFu currency_name");
215
+ if (!DECIMAL_HANDLINGS.includes(c.decimal_handling)) {
216
+ fail(`decimal_handling ph\u1EA3i l\xE0 ${DECIMAL_HANDLINGS.join(" / ")}`);
217
+ }
218
+ if (!Number.isInteger(c.decimal_scale) || c.decimal_scale < 0) fail("decimal_scale ph\u1EA3i l\xE0 s\u1ED1 nguy\xEAn \u2265 0");
219
+ if (c.decimal_handling === "READ" && c.decimal_allowed && (isBlank(c.minor_unit_singular) || isBlank(c.minor_unit_plural))) {
220
+ throw new MoneyReaderError(
221
+ "MINOR_UNIT_MISSING",
222
+ `Ti\u1EC1n t\u1EC7 ${code} d\xF9ng ch\u1EBF \u0111\u1ED9 READ nh\u01B0ng ch\u01B0a khai b\xE1o \u0111\u1EE7 \u0111\u01A1n v\u1ECB l\u1EBB s\u1ED1 \xEDt/s\u1ED1 nhi\u1EC1u`,
223
+ { currency_code: code }
224
+ );
225
+ }
226
+ }
227
+ function capitalizeFirst(text) {
228
+ return text.charAt(0).toUpperCase() + text.slice(1);
229
+ }
230
+ function createMoneyReader(options = {}) {
231
+ const source = options.currencies ?? DEFAULT_CURRENCIES;
232
+ if (!Array.isArray(source)) {
233
+ throw new MoneyReaderError("INVALID_CONFIG", "currencies ph\u1EA3i l\xE0 m\u1ED9t m\u1EA3ng");
234
+ }
235
+ const registry = /* @__PURE__ */ new Map();
236
+ for (const c of source) {
237
+ if (!c || c.active !== true) continue;
238
+ const code = normalizeCurrencyCode(c.currency_code);
239
+ if (code && !registry.has(code)) registry.set(code, { ...c, currency_code: code });
240
+ }
241
+ const getCurrency = (currencyCode) => registry.get(normalizeCurrencyCode(currencyCode));
242
+ const read = (amount, currencyCode, readOptions = {}) => {
243
+ const code = normalizeCurrencyCode(currencyCode);
244
+ const currency = registry.get(code);
245
+ if (!currency) {
246
+ throw new MoneyReaderError(
247
+ "CURRENCY_NOT_FOUND",
248
+ `Kh\xF4ng t\xECm th\u1EA5y c\u1EA5u h\xECnh ti\u1EC1n t\u1EC7 \u0111ang ho\u1EA1t \u0111\u1ED9ng cho m\xE3 "${code}"`,
249
+ { currency_code: code }
250
+ );
251
+ }
252
+ validateCurrency(currency);
253
+ const vndStyle = code === VND;
254
+ const { integer, fraction } = parseAmount(amount, readOptions);
255
+ let minorDigits = null;
256
+ switch (currency.decimal_handling) {
257
+ case "IGNORE":
258
+ break;
259
+ case "REJECT":
260
+ if (fraction !== "") {
261
+ throw new MoneyReaderError(
262
+ "DECIMAL_REJECTED",
263
+ `Ti\u1EC1n t\u1EC7 ${code} kh\xF4ng ch\u1EA5p nh\u1EADn ph\u1EA7n th\u1EADp ph\xE2n`,
264
+ { currency_code: code, amount: String(amount) }
265
+ );
266
+ }
267
+ break;
268
+ case "READ": {
269
+ const scale = currency.decimal_scale;
270
+ if (fraction.length > scale) {
271
+ throw new MoneyReaderError(
272
+ "DECIMAL_SCALE_EXCEEDED",
273
+ `Ph\u1EA7n th\u1EADp ph\xE2n v\u01B0\u1EE3t qu\xE1 ${scale} ch\u1EEF s\u1ED1 cho ph\xE9p c\u1EE7a ${code} (kh\xF4ng t\u1EF1 \u0111\u1ED9ng l\xE0m tr\xF2n)`,
274
+ { currency_code: code, amount: String(amount), decimal_scale: scale }
275
+ );
276
+ }
277
+ if (currency.decimal_allowed && fraction !== "") {
278
+ minorDigits = fraction.padEnd(scale, "0").replace(/^0+(?=\d)/, "");
279
+ }
280
+ break;
281
+ }
282
+ }
283
+ const parts = [readDigitsVi(integer, { vndStyle }), currency.currency_name.trim()];
284
+ if (minorDigits !== null && minorDigits !== "0") {
285
+ const unit = minorDigits === "1" ? currency.minor_unit_singular : currency.minor_unit_plural;
286
+ parts.push("v\xE0", readDigitsVi(minorDigits, { vndStyle }), String(unit).trim());
287
+ }
288
+ return capitalizeFirst(parts.join(" "));
289
+ };
290
+ const safeRead = (amount, currencyCode, readOptions) => {
291
+ try {
292
+ return { ok: true, text: read(amount, currencyCode, readOptions) };
293
+ } catch (err) {
294
+ if (err instanceof MoneyReaderError) return { ok: false, error: err };
295
+ throw err;
296
+ }
297
+ };
298
+ return {
299
+ read,
300
+ safeRead,
301
+ getCurrency,
302
+ listCurrencies: () => Array.from(registry.values())
303
+ };
304
+ }
305
+ var defaultReader;
306
+ var getDefaultReader = () => defaultReader ?? (defaultReader = createMoneyReader());
307
+ function readMoney(amount, currencyCode, options) {
308
+ return getDefaultReader().read(amount, currencyCode, options);
309
+ }
310
+ function safeReadMoney(amount, currencyCode, options) {
311
+ return getDefaultReader().safeRead(amount, currencyCode, options);
312
+ }
313
+
314
+ exports.DEFAULT_CURRENCIES = DEFAULT_CURRENCIES;
315
+ exports.MoneyReaderError = MoneyReaderError;
316
+ exports.createMoneyReader = createMoneyReader;
317
+ exports.isMoneyReaderError = isMoneyReaderError;
318
+ exports.normalizeCurrencyCode = normalizeCurrencyCode;
319
+ exports.parseAmount = parseAmount;
320
+ exports.readInteger = readInteger;
321
+ exports.readMoney = readMoney;
322
+ exports.safeReadMoney = safeReadMoney;
323
+ exports.scaleName = scaleName;
324
+ //# sourceMappingURL=index.cjs.map
325
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/currencies.ts","../src/errors.ts","../src/parse-amount.ts","../src/read-integer.ts","../src/money-reader.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { CurrencyConfig } from './types';\n\n/** Bảng cấu hình tiền tệ hiện hành (mục 7.1). */\nexport const DEFAULT_CURRENCIES: readonly CurrencyConfig[] = Object.freeze([\n {\n currency_code: 'VND',\n currency_name: 'đồng',\n decimal_handling: 'IGNORE',\n decimal_scale: 0,\n decimal_allowed: false,\n minor_unit_singular: null,\n minor_unit_plural: null,\n active: true,\n },\n {\n currency_code: 'USD',\n currency_name: 'đô la Mỹ',\n decimal_handling: 'READ',\n decimal_scale: 2,\n decimal_allowed: true,\n minor_unit_singular: 'cent',\n minor_unit_plural: 'cent',\n active: true,\n },\n {\n currency_code: 'EUR',\n currency_name: 'euro',\n decimal_handling: 'READ',\n decimal_scale: 2,\n decimal_allowed: true,\n minor_unit_singular: 'cent',\n minor_unit_plural: 'cent',\n active: true,\n },\n {\n currency_code: 'GBP',\n currency_name: 'bảng Anh',\n decimal_handling: 'READ',\n decimal_scale: 2,\n decimal_allowed: true,\n minor_unit_singular: 'penny',\n minor_unit_plural: 'pence',\n active: true,\n },\n {\n currency_code: 'JPY',\n currency_name: 'yên Nhật',\n decimal_handling: 'IGNORE',\n decimal_scale: 0,\n decimal_allowed: false,\n minor_unit_singular: null,\n minor_unit_plural: null,\n active: true,\n },\n {\n currency_code: 'SGD',\n currency_name: 'đô la Singapore',\n decimal_handling: 'READ',\n decimal_scale: 2,\n decimal_allowed: true,\n minor_unit_singular: 'cent',\n minor_unit_plural: 'cent',\n active: true,\n },\n] as CurrencyConfig[]);\n","export type MoneyReaderErrorCode =\n | 'CURRENCY_NOT_FOUND'\n | 'INVALID_AMOUNT'\n | 'DECIMAL_REJECTED'\n | 'DECIMAL_SCALE_EXCEEDED'\n | 'MINOR_UNIT_MISSING'\n | 'INVALID_CONFIG';\n\n/** Lỗi nghiệp vụ khi đọc số tiền. Dùng `code` để xử lý/hiển thị thông báo phù hợp. */\nexport class MoneyReaderError extends Error {\n readonly code: MoneyReaderErrorCode;\n readonly details?: Record<string, unknown>;\n\n constructor(code: MoneyReaderErrorCode, message: string, details?: Record<string, unknown>) {\n super(message);\n this.name = 'MoneyReaderError';\n this.code = code;\n this.details = details;\n // Giữ đúng prototype khi transpile xuống ES5.\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\nexport function isMoneyReaderError(err: unknown): err is MoneyReaderError {\n return err instanceof MoneyReaderError;\n}\n","import { MoneyReaderError } from './errors';\nimport type { AmountInput, ReadOptions } from './types';\n\nexport interface ParsedAmount {\n /** Phần nguyên dạng chuỗi chữ số, không có số 0 thừa ở đầu (\"0\" nếu bằng 0). */\n integer: string;\n /** Phần thập phân dạng chuỗi chữ số, đã bỏ số 0 thừa ở cuối (\"\" nếu không có). */\n fraction: string;\n}\n\nconst invalid = (input: unknown, reason: string): MoneyReaderError =>\n new MoneyReaderError('INVALID_AMOUNT', `Số tiền không hợp lệ: ${reason}`, { input: String(input) });\n\n/** Chuyển chuỗi số dạng mũ (\"1.5e+21\", \"1e-7\") thành dạng thập phân thường. */\nfunction expandExponent(str: string): string {\n const match = /^(\\d+)(?:\\.(\\d+))?e([+-]\\d+)$/i.exec(str);\n if (!match) return str;\n const intPart = match[1] ?? '';\n const fracPart = match[2] ?? '';\n const exp = Number(match[3]);\n const digits = intPart + fracPart;\n const pointPos = intPart.length + exp;\n if (pointPos <= 0) return `0.${'0'.repeat(-pointPos)}${digits}`;\n if (pointPos >= digits.length) return digits + '0'.repeat(pointPos - digits.length);\n return `${digits.slice(0, pointPos)}.${digits.slice(pointPos)}`;\n}\n\nfunction normalizeParts(integer: string, fraction: string): ParsedAmount {\n return {\n integer: integer.replace(/^0+(?=\\d)/, '') || '0',\n fraction: fraction.replace(/0+$/, ''),\n };\n}\n\n/**\n * Bước 1 & 3: chuẩn hóa số tiền và tách phần nguyên / phần thập phân.\n * Thao tác hoàn toàn trên chuỗi để tránh sai số dấu phẩy động và hỗ trợ số rất lớn.\n */\nexport function parseAmount(input: AmountInput, options: ReadOptions = {}): ParsedAmount {\n if (typeof input === 'bigint') {\n if (input < 0n) throw invalid(input, 'không hỗ trợ số âm');\n return normalizeParts(input.toString(), '');\n }\n\n if (typeof input === 'number') {\n if (!Number.isFinite(input)) throw invalid(input, 'phải là số hữu hạn');\n if (input < 0) throw invalid(input, 'không hỗ trợ số âm');\n const [i = '0', f = ''] = expandExponent(String(Math.abs(input))).split('.');\n return normalizeParts(i, f);\n }\n\n if (typeof input !== 'string') throw invalid(input, 'phải là number, bigint hoặc string');\n\n const decimalSeparator = options.decimalSeparator ?? '.';\n if (decimalSeparator !== '.' && decimalSeparator !== ',') {\n throw new MoneyReaderError('INVALID_CONFIG', `decimalSeparator không hợp lệ: ${String(decimalSeparator)}`);\n }\n const groupSeparator = decimalSeparator === '.' ? ',' : '.';\n\n let str = input.replace(/\\s+/g, '');\n if (str.startsWith('+')) str = str.slice(1);\n if (str.startsWith('-')) throw invalid(input, 'không hỗ trợ số âm');\n\n const g = `\\\\${groupSeparator}`;\n const d = `\\\\${decimalSeparator}`;\n // Phân cách nghìn (nếu có) phải đúng nhóm 3 chữ số => tránh hiểu nhầm \"12,01\" thành 1201.\n const pattern = new RegExp(`^(\\\\d{1,3}(?:${g}\\\\d{3})+|\\\\d*)(?:${d}(\\\\d*))?$`);\n const match = pattern.exec(str);\n const intRaw = match?.[1] ?? '';\n const fracRaw = match?.[2] ?? '';\n if (!match || (intRaw === '' && fracRaw === '')) {\n throw invalid(input, 'định dạng không đúng');\n }\n\n return normalizeParts(intRaw.split(groupSeparator).join('') || '0', fracRaw);\n}\n","/**\n * Logic đọc số nguyên tiếng Việt – CỐ ĐỊNH trong mã nguồn, không cấu hình qua danh mục (mục 4 & 8).\n */\n\nconst DIGITS = ['không', 'một', 'hai', 'ba', 'bốn', 'năm', 'sáu', 'bảy', 'tám', 'chín'] as const;\nconst BASE_SCALES = ['', 'nghìn', 'triệu'] as const;\n\nexport interface ReadIntegerOptions {\n /**\n * Áp dụng quy tắc riêng của VND:\n * - \"tư\" thay cho \"bốn\" khi hàng chục > 1 (24 → hai mươi tư).\n * - \"lẻ\" thay cho \"linh\" ở nhóm đơn vị tận cùng bên phải (105 → một trăm lẻ năm).\n */\n vndStyle?: boolean;\n}\n\nconst digit = (n: number): string => DIGITS[n] as string;\n\n/**\n * Tên bậc cho cụm thứ `index` (0 = cụm đơn vị, tính từ phải):\n * \"\", nghìn, triệu, tỷ, nghìn tỷ, triệu tỷ, tỷ tỷ, nghìn tỷ tỷ...\n */\nexport function scaleName(index: number): string {\n const parts: string[] = [];\n const base = BASE_SCALES[index % 3];\n if (base) parts.push(base);\n for (let i = 0; i < Math.floor(index / 3); i++) parts.push('tỷ');\n return parts.join(' ');\n}\n\ninterface GroupContext {\n /** Đã đọc một nhóm bậc cao hơn trước đó => bắt buộc đọc đủ hàng trăm. */\n full: boolean;\n /** Đây là nhóm đơn vị tận cùng bên phải. */\n isUnitGroup: boolean;\n vndStyle: boolean;\n}\n\n/** Đọc một nhóm 3 chữ số H/T/U (giá trị > 0). */\nfunction readGroup(h: number, t: number, u: number, ctx: GroupContext): string[] {\n const words: string[] = [];\n\n // Hàng trăm\n if (h > 0 || ctx.full) words.push(digit(h), 'trăm');\n\n // Hàng chục + hàng đơn vị\n if (t === 0) {\n if (u > 0) {\n if (words.length > 0) words.push(ctx.vndStyle && ctx.isUnitGroup ? 'lẻ' : 'linh');\n words.push(digit(u)); // sau linh/lẻ hoặc đứng đầu: một / bốn / năm\n }\n } else if (t === 1) {\n words.push('mười');\n if (u === 5) words.push('lăm');\n else if (u > 0) words.push(digit(u)); // mười một, mười bốn\n } else {\n words.push(digit(t), 'mươi');\n if (u === 1) words.push('mốt');\n else if (u === 4) words.push(ctx.vndStyle ? 'tư' : 'bốn');\n else if (u === 5) words.push('lăm');\n else if (u > 0) words.push(digit(u));\n }\n\n return words;\n}\n\n/**\n * Đọc chuỗi chữ số nguyên không âm (vd \"1005001\") thành chữ tiếng Việt (chữ thường).\n * Đầu vào phải chỉ gồm chữ số.\n */\nexport function readDigitsVi(digits: string, options: ReadIntegerOptions = {}): string {\n if (!/^\\d+$/.test(digits)) throw new TypeError(`Chuỗi số không hợp lệ: \"${digits}\"`);\n const normalized = digits.replace(/^0+(?=\\d)/, '');\n if (/^0+$/.test(normalized)) return digit(0);\n\n const vndStyle = options.vndStyle === true;\n\n // Chia nhóm 3 chữ số từ phải sang trái.\n const padded = normalized.padStart(Math.ceil(normalized.length / 3) * 3, '0');\n const groupCount = padded.length / 3;\n\n const words: string[] = [];\n for (let g = 0; g < groupCount; g++) {\n const index = groupCount - 1 - g; // vị trí cụm tính từ phải\n const chunk = padded.slice(g * 3, g * 3 + 3);\n const h = Number(chunk[0]);\n const t = Number(chunk[1]);\n const u = Number(chunk[2]);\n if (h === 0 && t === 0 && u === 0) continue; // bỏ qua nhóm 000\n\n words.push(...readGroup(h, t, u, { full: words.length > 0, isUnitGroup: index === 0, vndStyle }));\n const scale = scaleName(index);\n if (scale) words.push(scale);\n }\n\n return words.join(' ');\n}\n\n/**\n * Đọc số nguyên không âm thành chữ tiếng Việt (chữ thường, không kèm đơn vị tiền).\n * @example readInteger(1005001, { vndStyle: true }) // \"một triệu không trăm linh năm nghìn không trăm lẻ một\"\n */\nexport function readInteger(value: number | bigint | string, options: ReadIntegerOptions = {}): string {\n let digits: string;\n if (typeof value === 'bigint') {\n if (value < 0n) throw new RangeError('Không hỗ trợ số âm');\n digits = value.toString();\n } else if (typeof value === 'number') {\n if (!Number.isSafeInteger(value) || value < 0) {\n throw new RangeError('Giá trị number phải là số nguyên không âm an toàn; dùng bigint/string cho số lớn');\n }\n digits = String(value);\n } else {\n digits = value.trim();\n }\n return readDigitsVi(digits, options);\n}\n","import { DEFAULT_CURRENCIES } from './currencies';\nimport { MoneyReaderError } from './errors';\nimport { parseAmount } from './parse-amount';\nimport { readDigitsVi } from './read-integer';\nimport type { AmountInput, CurrencyConfig, MoneyReaderOptions, ReadOptions } from './types';\n\nconst DECIMAL_HANDLINGS = ['READ', 'IGNORE', 'REJECT'] as const;\nconst VND = 'VND';\n\nexport type SafeReadResult =\n | { ok: true; text: string }\n | { ok: false; error: MoneyReaderError };\n\nexport interface MoneyReader {\n /** Đọc số tiền thành chữ. Ném `MoneyReaderError` nếu lỗi nghiệp vụ. */\n read(amount: AmountInput, currencyCode: string, options?: ReadOptions): string;\n /** Giống `read` nhưng không ném lỗi – tiện cho binding UI. */\n safeRead(amount: AmountInput, currencyCode: string, options?: ReadOptions): SafeReadResult;\n /** Lấy cấu hình tiền tệ đang Active theo mã (đã chuẩn hóa), hoặc `undefined`. */\n getCurrency(currencyCode: string): CurrencyConfig | undefined;\n /** Danh sách tiền tệ đang Active. */\n listCurrencies(): CurrencyConfig[];\n}\n\n/** Bước 1: trim + viết hoa mã tiền tệ (\" vnd \" → \"VND\"). */\nexport function normalizeCurrencyCode(code: unknown): string {\n return String(code ?? '').trim().toUpperCase();\n}\n\nconst isBlank = (s: string | null | undefined): boolean => !s || !s.trim();\n\nfunction validateCurrency(c: CurrencyConfig): void {\n const code = normalizeCurrencyCode(c.currency_code);\n const fail = (msg: string) => {\n throw new MoneyReaderError('INVALID_CONFIG', `Cấu hình tiền tệ ${code} không hợp lệ: ${msg}`, { currency_code: code });\n };\n if (isBlank(c.currency_name)) fail('thiếu currency_name');\n if (!(DECIMAL_HANDLINGS as readonly string[]).includes(c.decimal_handling)) {\n fail(`decimal_handling phải là ${DECIMAL_HANDLINGS.join(' / ')}`);\n }\n if (!Number.isInteger(c.decimal_scale) || c.decimal_scale < 0) fail('decimal_scale phải là số nguyên ≥ 0');\n if (\n c.decimal_handling === 'READ' &&\n c.decimal_allowed &&\n (isBlank(c.minor_unit_singular) || isBlank(c.minor_unit_plural))\n ) {\n throw new MoneyReaderError(\n 'MINOR_UNIT_MISSING',\n `Tiền tệ ${code} dùng chế độ READ nhưng chưa khai báo đủ đơn vị lẻ số ít/số nhiều`,\n { currency_code: code },\n );\n }\n}\n\nfunction capitalizeFirst(text: string): string {\n return text.charAt(0).toUpperCase() + text.slice(1);\n}\n\n/**\n * Tạo bộ đọc số tiền với danh mục tiền tệ tùy biến (vd nạp từ API Master Data).\n * @example\n * const reader = createMoneyReader({ currencies: await api.getCurrencies() });\n * reader.read('12.02', 'GBP'); // \"Mười hai bảng Anh và hai pence\"\n */\nexport function createMoneyReader(options: MoneyReaderOptions = {}): MoneyReader {\n const source = options.currencies ?? DEFAULT_CURRENCIES;\n if (!Array.isArray(source)) {\n throw new MoneyReaderError('INVALID_CONFIG', 'currencies phải là một mảng');\n }\n\n // Chỉ bản ghi Active mới có hiệu lực; mã trùng thì lấy bản ghi Active đầu tiên.\n const registry = new Map<string, CurrencyConfig>();\n for (const c of source) {\n if (!c || c.active !== true) continue;\n const code = normalizeCurrencyCode(c.currency_code);\n if (code && !registry.has(code)) registry.set(code, { ...c, currency_code: code });\n }\n\n const getCurrency = (currencyCode: string) => registry.get(normalizeCurrencyCode(currencyCode));\n\n const read = (amount: AmountInput, currencyCode: string, readOptions: ReadOptions = {}): string => {\n // Bước 1-2: chuẩn hóa & tra cứu tiền tệ Active.\n const code = normalizeCurrencyCode(currencyCode);\n const currency = registry.get(code);\n if (!currency) {\n throw new MoneyReaderError(\n 'CURRENCY_NOT_FOUND',\n `Không tìm thấy cấu hình tiền tệ đang hoạt động cho mã \"${code}\"`,\n { currency_code: code },\n );\n }\n validateCurrency(currency);\n const vndStyle = code === VND;\n\n // Bước 3: tách phần nguyên / thập phân.\n const { integer, fraction } = parseAmount(amount, readOptions);\n\n // Bước 5: xử lý phần thập phân.\n let minorDigits: string | null = null;\n switch (currency.decimal_handling) {\n case 'IGNORE':\n break;\n case 'REJECT':\n if (fraction !== '') {\n throw new MoneyReaderError(\n 'DECIMAL_REJECTED',\n `Tiền tệ ${code} không chấp nhận phần thập phân`,\n { currency_code: code, amount: String(amount) },\n );\n }\n break;\n case 'READ': {\n const scale = currency.decimal_scale;\n if (fraction.length > scale) {\n throw new MoneyReaderError(\n 'DECIMAL_SCALE_EXCEEDED',\n `Phần thập phân vượt quá ${scale} chữ số cho phép của ${code} (không tự động làm tròn)`,\n { currency_code: code, amount: String(amount), decimal_scale: scale },\n );\n }\n if (currency.decimal_allowed && fraction !== '') {\n minorDigits = fraction.padEnd(scale, '0').replace(/^0+(?=\\d)/, '');\n }\n break;\n }\n }\n\n // Bước 4 & 6: đọc phần nguyên + tên đơn vị chính.\n const parts: string[] = [readDigitsVi(integer, { vndStyle }), currency.currency_name.trim()];\n\n // Bước 7: ghép phần lẻ.\n if (minorDigits !== null && minorDigits !== '0') {\n const unit = minorDigits === '1' ? currency.minor_unit_singular : currency.minor_unit_plural;\n parts.push('và', readDigitsVi(minorDigits, { vndStyle }), String(unit).trim());\n }\n\n // Bước 8: viết hoa chữ cái đầu.\n return capitalizeFirst(parts.join(' '));\n };\n\n const safeRead = (amount: AmountInput, currencyCode: string, readOptions?: ReadOptions): SafeReadResult => {\n try {\n return { ok: true, text: read(amount, currencyCode, readOptions) };\n } catch (err) {\n if (err instanceof MoneyReaderError) return { ok: false, error: err };\n throw err;\n }\n };\n\n return {\n read,\n safeRead,\n getCurrency,\n listCurrencies: () => Array.from(registry.values()),\n };\n}\n\nlet defaultReader: MoneyReader | undefined;\nconst getDefaultReader = () => (defaultReader ??= createMoneyReader());\n\n/**\n * Đọc số tiền thành chữ với danh mục mặc định (`DEFAULT_CURRENCIES`).\n * @example readMoney(1005001, 'VND') // \"Một triệu không trăm linh năm nghìn không trăm lẻ một đồng\"\n */\nexport function readMoney(amount: AmountInput, currencyCode: string, options?: ReadOptions): string {\n return getDefaultReader().read(amount, currencyCode, options);\n}\n\n/** Phiên bản không ném lỗi của `readMoney`. */\nexport function safeReadMoney(amount: AmountInput, currencyCode: string, options?: ReadOptions): SafeReadResult {\n return getDefaultReader().safeRead(amount, currencyCode, options);\n}\n"]}