Skip to content

Commit 1acb5e4

Browse files
committed
Harden CSV export API
1 parent cbdd464 commit 1acb5e4

6 files changed

Lines changed: 97 additions & 36 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ jsonToCsv(rows, {
110110
});
111111
```
112112

113+
The generic API accepts normal TypeScript interfaces; your row type does not need an index signature.
114+
113115
## CSV safety
114116

115117
Values are escaped according to normal CSV rules:
@@ -128,7 +130,7 @@ name,note
128130
""Line 2"""
129131
```
130132

131-
When exporting to spreadsheets, use `escapeFormulae` to reduce formula-injection risk:
133+
When exporting to spreadsheets, use `escapeFormulae` to reduce formula-injection risk. Values that start with a formula marker, even after leading whitespace, are prefixed before CSV escaping:
132134

133135
```ts
134136
jsonToCsv([{ value: '=SUM(A1:A2)' }], {

src/escape.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,5 @@ export function escapeCsvCell(value: string, options: EscapeCsvCellOptions = {})
2323
}
2424

2525
function startsLikeFormula(value: string): boolean {
26-
return /^[=+\-@\t\r]/.test(value);
26+
return /^\s*[=+\-@]/.test(value) || /^[\t\r]/.test(value);
2727
}

src/flatten.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CsvRecord } from './types.js';
22

3-
export function flattenRecord(record: CsvRecord, separator = '.'): CsvRecord {
3+
export function flattenRecord(record: object, separator = '.'): CsvRecord {
44
const output: CsvRecord = {};
55
flattenInto(record, '', output, separator, new WeakSet<object>());
66
return output;

src/index.ts

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { flattenRecord, isPlainObject } from './flatten.js';
33
import type {
44
CsvColumnInput,
55
CsvPrimitive,
6-
CsvRecord,
76
JsonToCsvOptions,
87
ResolvedCsvColumn,
98
ResolvedJsonToCsvOptions
@@ -15,13 +14,14 @@ export type {
1514
CsvColumnInput,
1615
CsvPrimitive,
1716
CsvRecord,
18-
JsonToCsvOptions
17+
JsonToCsvOptions,
18+
ResolvedCsvColumn
1919
} from './types.js';
2020

2121
export { escapeCsvCell } from './escape.js';
2222
export { flattenRecord } from './flatten.js';
2323

24-
export function jsonToCsv<TRecord extends CsvRecord>(
24+
export function jsonToCsv<TRecord extends object>(
2525
records: readonly TRecord[],
2626
options: JsonToCsvOptions<TRecord> = {}
2727
): string {
@@ -60,15 +60,15 @@ export function jsonToCsv<TRecord extends CsvRecord>(
6060

6161
export const toCsv = jsonToCsv;
6262

63-
export function inferCsvColumns<TRecord extends CsvRecord>(
63+
export function inferCsvColumns<TRecord extends object>(
6464
records: readonly TRecord[],
6565
options: Pick<JsonToCsvOptions<TRecord>, 'flatten' | 'sortColumns'> = {}
6666
): Array<ResolvedCsvColumn<TRecord>> {
6767
const resolved = resolveOptions(options);
6868
return resolveColumns(assertRecords(records), resolved);
6969
}
7070

71-
function resolveOptions<TRecord extends CsvRecord>(
71+
function resolveOptions<TRecord extends object>(
7272
options: JsonToCsvOptions<TRecord>
7373
): ResolvedJsonToCsvOptions<TRecord> {
7474
const delimiter = options.delimiter ?? ',';
@@ -111,7 +111,7 @@ function resolveOptions<TRecord extends CsvRecord>(
111111
};
112112
}
113113

114-
function assertRecords<TRecord extends CsvRecord>(records: readonly TRecord[]): readonly TRecord[] {
114+
function assertRecords<TRecord extends object>(records: readonly TRecord[]): readonly TRecord[] {
115115
if (!Array.isArray(records)) {
116116
throw new TypeError('json-csv-kit expects an array of records.');
117117
}
@@ -125,7 +125,7 @@ function assertRecords<TRecord extends CsvRecord>(records: readonly TRecord[]):
125125
return records;
126126
}
127127

128-
function resolveColumns<TRecord extends CsvRecord>(
128+
function resolveColumns<TRecord extends object>(
129129
records: readonly TRecord[],
130130
options: ResolvedJsonToCsvOptions<TRecord>
131131
): Array<ResolvedCsvColumn<TRecord>> {
@@ -136,7 +136,7 @@ function resolveColumns<TRecord extends CsvRecord>(
136136
const keys = new Set<string>();
137137

138138
for (const record of records) {
139-
const source = options.flatten ? flattenRecord(record) : record;
139+
const source: object = options.flatten ? flattenRecord(record) : record;
140140

141141
for (const key of Object.keys(source)) {
142142
keys.add(key);
@@ -156,7 +156,7 @@ function resolveColumns<TRecord extends CsvRecord>(
156156
}));
157157
}
158158

159-
function resolveColumn<TRecord extends CsvRecord>(
159+
function resolveColumn<TRecord extends object>(
160160
column: CsvColumnInput<TRecord>
161161
): ResolvedCsvColumn<TRecord> {
162162
if (typeof column === 'string') {
@@ -176,7 +176,7 @@ function resolveColumn<TRecord extends CsvRecord>(
176176
};
177177
}
178178

179-
function readColumnValue<TRecord extends CsvRecord>(
179+
function readColumnValue<TRecord extends object>(
180180
record: TRecord,
181181
column: ResolvedCsvColumn<TRecord>,
182182
index: number
@@ -188,9 +188,9 @@ function readColumnValue<TRecord extends CsvRecord>(
188188
return getPath(record, column.path);
189189
}
190190

191-
function getPath(record: CsvRecord, path: string): unknown {
191+
function getPath(record: object, path: string): unknown {
192192
if (Object.hasOwn(record, path)) {
193-
return record[path];
193+
return (record as Record<string, unknown>)[path];
194194
}
195195

196196
const segments = path.split('.');
@@ -201,13 +201,13 @@ function getPath(record: CsvRecord, path: string): unknown {
201201
return undefined;
202202
}
203203

204-
current = (current as CsvRecord)[segment];
204+
current = (current as Record<string, unknown>)[segment];
205205
}
206206

207207
return current;
208208
}
209209

210-
function formatRow<TRecord extends CsvRecord>(
210+
function formatRow<TRecord extends object>(
211211
values: readonly string[],
212212
options: ResolvedJsonToCsvOptions<TRecord>
213213
): string {
@@ -222,7 +222,7 @@ function formatRow<TRecord extends CsvRecord>(
222222
.join(options.delimiter);
223223
}
224224

225-
function formatValue<TRecord extends CsvRecord>(
225+
function formatValue<TRecord extends object>(
226226
value: unknown,
227227
options: ResolvedJsonToCsvOptions<TRecord>
228228
): string {
@@ -254,21 +254,40 @@ function formatValue<TRecord extends CsvRecord>(
254254
}
255255

256256
function safeJsonStringify(value: unknown): string {
257-
const seen = new WeakSet<object>();
257+
return JSON.stringify(toJsonSafe(value, new WeakSet<object>())) ?? '';
258+
}
258259

259-
return JSON.stringify(value, (_key, child) => {
260-
if (typeof child === 'bigint') {
261-
return String(child);
262-
}
260+
function toJsonSafe(value: unknown, seen: WeakSet<object>): unknown {
261+
if (typeof value === 'bigint') {
262+
return String(value);
263+
}
263264

264-
if (child && typeof child === 'object') {
265-
if (seen.has(child)) {
266-
return '[Circular]';
267-
}
265+
if (!value || typeof value !== 'object') {
266+
return value;
267+
}
268268

269-
seen.add(child);
270-
}
269+
if (value instanceof Date) {
270+
return value.toISOString();
271+
}
272+
273+
if (seen.has(value)) {
274+
return '[Circular]';
275+
}
276+
277+
seen.add(value);
278+
279+
if (Array.isArray(value)) {
280+
const output = value.map((item) => toJsonSafe(item, seen));
281+
seen.delete(value);
282+
return output;
283+
}
284+
285+
const output: Record<string, unknown> = {};
286+
287+
for (const [key, child] of Object.entries(value)) {
288+
output[key] = toJsonSafe(child, seen);
289+
}
271290

272-
return child;
273-
}) ?? '';
291+
seen.delete(value);
292+
return output;
274293
}

src/types.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export type CsvRecord = Record<string, unknown>;
44

55
export type ArrayValueMode = 'json' | 'join' | 'empty';
66

7-
export interface CsvColumn<TRecord extends CsvRecord = CsvRecord> {
7+
export interface CsvColumn<TRecord extends object = CsvRecord> {
88
/**
99
* Stable column id and default source path.
1010
*/
@@ -35,11 +35,11 @@ export interface CsvColumn<TRecord extends CsvRecord = CsvRecord> {
3535
formatter?: (value: unknown, record: TRecord, index: number) => CsvPrimitive;
3636
}
3737

38-
export type CsvColumnInput<TRecord extends CsvRecord = CsvRecord> =
38+
export type CsvColumnInput<TRecord extends object = CsvRecord> =
3939
| string
4040
| CsvColumn<TRecord>;
4141

42-
export interface JsonToCsvOptions<TRecord extends CsvRecord = CsvRecord> {
42+
export interface JsonToCsvOptions<TRecord extends object = CsvRecord> {
4343
/**
4444
* Explicit columns. When omitted, columns are inferred from records.
4545
*/
@@ -123,7 +123,7 @@ export interface JsonToCsvOptions<TRecord extends CsvRecord = CsvRecord> {
123123
dateFormatter?: (date: Date) => string;
124124
}
125125

126-
export interface ResolvedJsonToCsvOptions<TRecord extends CsvRecord = CsvRecord> {
126+
export interface ResolvedJsonToCsvOptions<TRecord extends object = CsvRecord> {
127127
columns?: Array<CsvColumnInput<TRecord>>;
128128
includeHeaders: boolean;
129129
flatten: boolean;
@@ -138,7 +138,7 @@ export interface ResolvedJsonToCsvOptions<TRecord extends CsvRecord = CsvRecord>
138138
dateFormatter: (date: Date) => string;
139139
}
140140

141-
export interface ResolvedCsvColumn<TRecord extends CsvRecord = CsvRecord> {
141+
export interface ResolvedCsvColumn<TRecord extends object = CsvRecord> {
142142
key: string;
143143
header: string;
144144
path: string;

tests/index.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,30 @@ describe('json-csv-kit', () => {
5656
expect(csv).toBe('total\n$12.34');
5757
});
5858

59+
test('accepts typed interfaces without an index signature', () => {
60+
interface Order {
61+
customer: {
62+
name: string;
63+
};
64+
totalCents: number;
65+
}
66+
67+
const orders: Order[] = [{ customer: { name: 'Ada' }, totalCents: 1234 }];
68+
69+
expect(
70+
jsonToCsv(orders, {
71+
columns: [
72+
{ key: 'customer', path: 'customer.name' },
73+
{
74+
key: 'total',
75+
accessor: (row) => row.totalCents,
76+
formatter: (value) => `$${Number(value) / 100}`
77+
}
78+
]
79+
})
80+
).toBe('customer,total\nAda,$12.34');
81+
});
82+
5983
test('flattens nested plain objects by default', () => {
6084
expect(jsonToCsv([{ user: { name: 'Ada' } }])).toBe('user.name\nAda');
6185
});
@@ -85,6 +109,16 @@ describe('json-csv-kit', () => {
85109
expect(jsonToCsv([record])).toBe('id,self\n1,[Circular]');
86110
});
87111

112+
test('does not mark shared nested references as circular', () => {
113+
const shared = { name: 'Ada' };
114+
115+
expect(
116+
jsonToCsv([{ payload: { primary: shared, secondary: shared } }], {
117+
flatten: false
118+
})
119+
).toBe('payload\n"{""primary"":{""name"":""Ada""},""secondary"":{""name"":""Ada""}}"');
120+
});
121+
88122
test('can join arrays', () => {
89123
expect(jsonToCsv([{ tags: ['admin', 'ops'] }], { arrayMode: 'join' })).toBe(
90124
'tags\n"admin, ops"'
@@ -119,6 +153,12 @@ describe('json-csv-kit', () => {
119153
);
120154
});
121155

156+
test('escapes spreadsheet formula values after leading whitespace', () => {
157+
expect(jsonToCsv([{ value: ' =SUM(A1:A2)' }], { escapeFormulae: true })).toBe(
158+
"value\n' =SUM(A1:A2)"
159+
);
160+
});
161+
122162
test('sorts inferred columns when requested', () => {
123163
expect(jsonToCsv([{ b: 2, a: 1 }], { sortColumns: true })).toBe('a,b\n1,2');
124164
});

0 commit comments

Comments
 (0)