@verifyhash/csv-lite 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -0
  3. package/index.js +228 -0
  4. package/package.json +37 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 <OWNER-NAME PLACEHOLDER>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # csv-lite
2
+
3
+ <!-- publish-prep -->
4
+ > **TODO (owner): pick the final npm name/scope before publishing.**
5
+ > The npm name is **not** finalized. `package.json` uses the placeholder
6
+ > `PLACEHOLDER-csv-lite` — replace it with the real published name/scope
7
+ > before any `npm publish`. Graduation to a package registry is a
8
+ > **needs-human** decision, not something this project does on its own.
9
+
10
+ A tiny, **zero-dependency, zero-network** Node.js library for **RFC 4180** CSV:
11
+ parse a CSV string into rows (or objects), and turn rows back into CSV text with
12
+ minimal, correct quoting. One CommonJS file (`index.js`), no install step, no
13
+ I/O — drop it into any project and `require` it.
14
+
15
+ Spec reference: <https://www.rfc-editor.org/rfc/rfc4180>
16
+
17
+ ## Who it's for
18
+
19
+ JavaScript/Node developers who need to read or write CSV correctly — quoted
20
+ fields, commas and newlines inside quotes, `""` escapes, `;`/tab delimiters,
21
+ CRLF vs LF — **without** pulling in a larger parser like
22
+ [`csv-parse`](https://www.npmjs.com/package/csv-parse) or
23
+ [`papaparse`](https://www.npmjs.com/package/papaparse) and their footprint.
24
+ Good for build scripts, CLIs, small ETL glue, tests, config import/export, and
25
+ anywhere you want auditable, single-file CSV handling.
26
+
27
+ If you need to stream gigabyte files, sniff the delimiter automatically, coerce
28
+ types, or handle malformed-quote recovery, use one of the full packages above.
29
+ This library deliberately covers the well-formed RFC 4180 common case.
30
+
31
+ ## Install / use
32
+
33
+ No install — copy `index.js`, or `require` it directly:
34
+
35
+ ```js
36
+ const csv = require('./index.js');
37
+
38
+ // parse -> 2D array of strings
39
+ csv.parse('a,"b,c",d');
40
+ // [['a', 'b,c', 'd']]
41
+
42
+ // parse with header -> array of objects keyed by the first row
43
+ csv.parse('name,age\nAlice,30\nBob,25', { header: true });
44
+ // [{ name: 'Alice', age: '30' }, { name: 'Bob', age: '25' }]
45
+
46
+ // stringify a 2D array (quotes only where required)
47
+ csv.stringify([['x', 'y,z', 'q"1']]);
48
+ // 'x,"y,z","q""1"'
49
+
50
+ // stringify array-of-objects with an explicit column order
51
+ csv.stringify(
52
+ [{ name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }],
53
+ { columns: ['name', 'age'] }
54
+ );
55
+ // 'name,age\nAlice,30\nBob,25'
56
+
57
+ // custom delimiter (semicolon, tab, pipe, ...)
58
+ csv.parse('a;b;c', { delimiter: ';' }); // [['a', 'b', 'c']]
59
+ ```
60
+
61
+ Complements the browser-based [/csv-to-json/](https://polyatic.com/csv-to-json/)
62
+ hub tool: same RFC 4180 rules, but as a reusable Node module.
63
+
64
+ ## API
65
+
66
+ ### `parse(text, options) → string[][] | object[]`
67
+
68
+ Parse CSV text into rows. Every value is returned as a **string** — no type
69
+ coercion.
70
+
71
+ Options:
72
+
73
+ | option | default | meaning |
74
+ | ----------- | ------- | -------------------------------------------------------------- |
75
+ | `delimiter` | `','` | Single-character field separator (e.g. `';'`, `'\t'`, `'\|'`). |
76
+ | `header` | `false` | When `true`, use the first row as keys and return objects. |
77
+
78
+ What it handles per RFC 4180:
79
+
80
+ - **Quoted fields** — a field wrapped in `"..."`; the delimiter, `CR`, `LF`, and
81
+ `CRLF` are literal data inside the quotes.
82
+ - **Embedded delimiters** — `a,"b,c",d` → `['a', 'b,c', 'd']`.
83
+ - **Embedded newlines** — `"line1\nline2"` stays one field; the row is not split.
84
+ - **Doubled-quote escapes** — `""` inside a quoted field is one literal `"`.
85
+ - **CRLF and LF** record separators, including mixed within one document.
86
+ - **A single trailing newline** is ignored — it does not produce an extra empty
87
+ record. (A blank line *in the middle* of the data is a real one-field row `['']`.)
88
+ - **Empty input** (`''`) returns `[]`.
89
+
90
+ With `header: true`, the first row becomes the object keys and the remaining
91
+ rows become objects. A header-only document returns `[]`.
92
+
93
+ ### `stringify(rows, options) → string`
94
+
95
+ Serialize rows to CSV text. Output uses `LF` (`\n`) record separators and has
96
+ **no trailing newline**.
97
+
98
+ Options:
99
+
100
+ | option | default | meaning |
101
+ | ----------- | ------- | ------------------------------------------------------------------------- |
102
+ | `delimiter` | `','` | Single-character field separator. |
103
+ | `columns` | — | When given, `rows` is an **array of objects**; emits a header row of these names first, then projects each object onto them in order. |
104
+
105
+ Quoting rule: a field is quoted **only when it must be** — it contains the
106
+ delimiter, a double-quote, `CR`, or `LF`. Embedded double-quotes are escaped by
107
+ doubling (`"` → `""`). Non-string values are coerced with `String()`;
108
+ `null`/`undefined` become an empty, unquoted field.
109
+
110
+ `parse` and `stringify` are exact inverses for well-formed data — see the
111
+ `parse -> stringify -> parse` stability test in `test/index.test.js`.
112
+
113
+ ## Limits (please read)
114
+
115
+ This is an honest, deliberately small library. It does **not** do everything a
116
+ full CSV package does:
117
+
118
+ - **No streaming.** `parse(text)` takes a complete string and returns the fully
119
+ materialized result in memory; `stringify(rows)` builds the whole output
120
+ string. This is explicitly **out of scope** — for files too large to fit in
121
+ memory, use a streaming parser such as `csv-parse`. As a rough guide, keep
122
+ inputs to something comfortably under available RAM (tens of MB is fine; multi-
123
+ gigabyte files are not what this is for).
124
+ - **RFC 4180 subset.** It targets well-formed RFC 4180. It does **not** attempt
125
+ recovery from malformed quoting (e.g. a `"` in the middle of an unquoted
126
+ field, or an unterminated quote — an unterminated quoted field simply runs to
127
+ end-of-input). There is no automatic delimiter detection, no comment-line
128
+ (`#`) skipping, no BOM stripping, and no quote/escape-character customization
129
+ (the quote character is always `"`).
130
+ - **Ragged rows are preserved, not normalized, in array mode.** `parse` in the
131
+ default (non-header) mode returns each row exactly as many fields as it had —
132
+ it does **not** pad or truncate rows to a uniform width, so different rows may
133
+ have different lengths. Only in `header: true` mode are objects shaped by the
134
+ header: rows with **fewer** cells than the header get `''` for the missing
135
+ keys, and any **extra** cells beyond the header width are dropped.
136
+ - **Strings only, no type coercion.** `"30"` stays the string `"30"`; there is
137
+ no number/boolean/date inference. Convert types yourself after parsing.
138
+
139
+ ## Running the tests
140
+
141
+ One command, Node's built-in `assert` only — no test runner, no dependencies:
142
+
143
+ ```sh
144
+ node test/index.test.js
145
+ ```
146
+
147
+ It prints `csv-lite: all N tests passed.` and exits `0` on success. Fixtures
148
+ cover embedded delimiters, embedded newlines inside quotes, doubled-quote
149
+ escapes, header-mode object round-trip, custom delimiters (`;` and tab), CRLF vs
150
+ LF, and a `parse -> stringify -> parse` round-trip that is byte-stable.
151
+
152
+ ## License
153
+
154
+ MIT — see `LICENSE`.
package/index.js ADDED
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * csv-lite — a zero-dependency, zero-network RFC 4180 CSV parser and
5
+ * stringifier for Node.js.
6
+ *
7
+ * No I/O, no streaming, no dependencies. Two pure functions over strings:
8
+ * parse(text, options) -> 2D array of strings, or array of objects
9
+ * stringify(rows, options) -> CSV text
10
+ *
11
+ * Spec reference: https://www.rfc-editor.org/rfc/rfc4180
12
+ *
13
+ * Streaming is explicitly OUT OF SCOPE: parse() takes a complete string and
14
+ * returns the fully materialized result. For multi-gigabyte files that will
15
+ * not fit in memory, use a streaming parser such as `csv-parse`.
16
+ */
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // parse
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Parse CSV text into rows.
24
+ *
25
+ * @param {string} text The CSV document.
26
+ * @param {object} [options]
27
+ * @param {string} [options.delimiter=','] Single-character field delimiter.
28
+ * @param {boolean} [options.header=false] When true, treat the first row as a
29
+ * header and return an array of objects keyed by the header names.
30
+ * @returns {string[][] | Object<string,string>[]}
31
+ *
32
+ * Behavior (RFC 4180):
33
+ * - Fields may be wrapped in double quotes. Inside a quoted field, the
34
+ * delimiter, CR, LF and CRLF are literal data.
35
+ * - A double-quote inside a quoted field is written as two double-quotes ("").
36
+ * - Records are separated by CRLF or LF (both accepted, mixed is fine).
37
+ * - A single trailing newline at the end of the document is ignored (it does
38
+ * not produce an extra empty record).
39
+ * - Every value is returned as a string; no type coercion is performed.
40
+ * - Empty input ("") yields an empty array ([]).
41
+ */
42
+ function parse(text, options) {
43
+ options = options || {};
44
+ const delimiter = options.delimiter == null ? ',' : options.delimiter;
45
+ const header = !!options.header;
46
+
47
+ if (typeof text !== 'string') {
48
+ throw new TypeError('csv-lite: parse(text) expects a string');
49
+ }
50
+ if (typeof delimiter !== 'string' || delimiter.length !== 1) {
51
+ throw new TypeError('csv-lite: delimiter must be a single character');
52
+ }
53
+ if (delimiter === '"' || delimiter === '\r' || delimiter === '\n') {
54
+ throw new TypeError('csv-lite: delimiter may not be a quote or newline');
55
+ }
56
+
57
+ const rows = [];
58
+ let field = '';
59
+ let row = [];
60
+ let inQuotes = false;
61
+ // Tracks whether the current record has produced any content at all, so we
62
+ // can distinguish a genuine empty trailing record from the ignorable
63
+ // trailing newline.
64
+ let sawAnyChar = false;
65
+ const len = text.length;
66
+
67
+ for (let i = 0; i < len; i++) {
68
+ const ch = text[i];
69
+
70
+ if (inQuotes) {
71
+ if (ch === '"') {
72
+ // Either an escaped quote ("") or the closing quote.
73
+ if (i + 1 < len && text[i + 1] === '"') {
74
+ field += '"';
75
+ i++; // consume the second quote of the pair
76
+ } else {
77
+ inQuotes = false;
78
+ }
79
+ } else {
80
+ field += ch;
81
+ }
82
+ continue;
83
+ }
84
+
85
+ if (ch === '"') {
86
+ inQuotes = true;
87
+ sawAnyChar = true;
88
+ continue;
89
+ }
90
+
91
+ if (ch === delimiter) {
92
+ row.push(field);
93
+ field = '';
94
+ sawAnyChar = true;
95
+ continue;
96
+ }
97
+
98
+ if (ch === '\r') {
99
+ // Handle CRLF as a single record separator; a lone CR also ends a record.
100
+ if (i + 1 < len && text[i + 1] === '\n') i++;
101
+ row.push(field);
102
+ rows.push(row);
103
+ field = '';
104
+ row = [];
105
+ sawAnyChar = false;
106
+ continue;
107
+ }
108
+
109
+ if (ch === '\n') {
110
+ row.push(field);
111
+ rows.push(row);
112
+ field = '';
113
+ row = [];
114
+ sawAnyChar = false;
115
+ continue;
116
+ }
117
+
118
+ field += ch;
119
+ sawAnyChar = true;
120
+ }
121
+
122
+ // Flush the final record. We emit it unless the document ended exactly on a
123
+ // record separator (trailing newline) with nothing after it.
124
+ if (sawAnyChar || field !== '' || row.length > 0) {
125
+ row.push(field);
126
+ rows.push(row);
127
+ }
128
+
129
+ if (!header) return rows;
130
+
131
+ // header:true -> array of objects keyed by the first row.
132
+ if (rows.length === 0) return [];
133
+ const keys = rows[0];
134
+ const out = [];
135
+ for (let r = 1; r < rows.length; r++) {
136
+ const src = rows[r];
137
+ const obj = {};
138
+ for (let c = 0; c < keys.length; c++) {
139
+ // Ragged rows: missing trailing cells become '' (empty string); this
140
+ // keeps every object shaped by the header rather than by the data row.
141
+ obj[keys[c]] = c < src.length ? src[c] : '';
142
+ }
143
+ out.push(obj);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // stringify
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Serialize rows to CSV text.
154
+ *
155
+ * @param {Array} rows A 2D array of cell values, OR an array of objects when
156
+ * `columns` is provided.
157
+ * @param {object} [options]
158
+ * @param {string} [options.delimiter=','] Single-character field delimiter.
159
+ * @param {string[]} [options.columns] When given, `rows` is treated as an
160
+ * array of objects; a header row of these column names is emitted first
161
+ * and each object is projected onto them in order.
162
+ * @returns {string} CSV text with LF (\n) record separators and no trailing
163
+ * newline.
164
+ *
165
+ * Quoting: a field is quoted ONLY when it must be — it contains the delimiter,
166
+ * a double-quote, CR, or LF. Embedded double-quotes are escaped by doubling.
167
+ * Non-string values are coerced with String(); null/undefined become '' (an
168
+ * empty, unquoted field).
169
+ */
170
+ function stringify(rows, options) {
171
+ options = options || {};
172
+ const delimiter = options.delimiter == null ? ',' : options.delimiter;
173
+ const columns = options.columns;
174
+
175
+ if (!Array.isArray(rows)) {
176
+ throw new TypeError('csv-lite: stringify(rows) expects an array');
177
+ }
178
+ if (typeof delimiter !== 'string' || delimiter.length !== 1) {
179
+ throw new TypeError('csv-lite: delimiter must be a single character');
180
+ }
181
+ if (delimiter === '"' || delimiter === '\r' || delimiter === '\n') {
182
+ throw new TypeError('csv-lite: delimiter may not be a quote or newline');
183
+ }
184
+
185
+ const needsQuote = (s) =>
186
+ s.indexOf(delimiter) !== -1 ||
187
+ s.indexOf('"') !== -1 ||
188
+ s.indexOf('\n') !== -1 ||
189
+ s.indexOf('\r') !== -1;
190
+
191
+ const cell = (value) => {
192
+ let s;
193
+ if (value == null) s = '';
194
+ else s = String(value);
195
+ if (needsQuote(s)) {
196
+ return '"' + s.replace(/"/g, '""') + '"';
197
+ }
198
+ return s;
199
+ };
200
+
201
+ const lines = [];
202
+
203
+ if (columns) {
204
+ if (!Array.isArray(columns)) {
205
+ throw new TypeError('csv-lite: columns must be an array of names');
206
+ }
207
+ // Header row.
208
+ lines.push(columns.map(cell).join(delimiter));
209
+ // Object rows projected onto columns.
210
+ for (const obj of rows) {
211
+ const src = obj || {};
212
+ lines.push(columns.map((key) => cell(src[key])).join(delimiter));
213
+ }
214
+ } else {
215
+ for (const row of rows) {
216
+ if (!Array.isArray(row)) {
217
+ throw new TypeError(
218
+ 'csv-lite: without `columns`, each row must be an array'
219
+ );
220
+ }
221
+ lines.push(row.map(cell).join(delimiter));
222
+ }
223
+ }
224
+
225
+ return lines.join('\n');
226
+ }
227
+
228
+ module.exports = { parse, stringify };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@verifyhash/csv-lite",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency RFC 4180 CSV parser and stringifier for Node.js: quoted fields, embedded delimiters/newlines, doubled-quote escapes, header-object mode, custom delimiters.",
5
+ "keywords": [
6
+ "csv",
7
+ "rfc4180",
8
+ "parse",
9
+ "stringify",
10
+ "delimiter",
11
+ "quote",
12
+ "tsv",
13
+ "header",
14
+ "zero-dependency"
15
+ ],
16
+ "main": "index.js",
17
+ "scripts": {
18
+ "test": "node test/index.test.js"
19
+ },
20
+ "license": "MIT",
21
+ "files": [
22
+ "index.js",
23
+ "README.md"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/verifyhash/libs.git",
31
+ "directory": "csv-lite"
32
+ },
33
+ "homepage": "https://github.com/verifyhash/libs/tree/main/csv-lite#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/verifyhash/libs/issues"
36
+ }
37
+ }